Getting started
A dev UI for creative coding: tactile parameter tweaking, GPU texture inspection and signal plots. It has a vanilla TypeScript API and an engine-agnostic core; three.js support comes as an extension. The panel is built with React Aria. It's bundled privately with Preact and mounted in a shadow root, so your app never sees React.
import { gui } from '@haxiomic/devui';
gui.add(material, 'roughness'); // slider 0–1, inferred from the name
gui.add(mesh, 'position'); // {x,y,z} → scrub fields, written in place
const p = gui.folder('Fluid').params({ // a live, typed object
viscosity: 0.2,
dye: '#ff6a00',
curl: { value: 30, min: 0, max: 50 },
mode: { value: 'advect', options: ['advect', 'diffuse'] },
reset: () => sim.reset(),
});
// in your frame loop, just read p.viscosity
The guide: https://haxiomic.github.io/devui/guide/, a page per feature with a live example, and the API. Demos: https://haxiomic.github.io/devui/ (both deployed from main by .github/workflows/pages.yml).
Status: in active development, with no npm release yet. See docs/ARCHITECTURE.md for the design, what's done and what's next.
Install
npm install github:haxiomic/devui # builds dist/ on install
Installing from GitHub runs the package's build (its prepare script), so npm installs the whole dev toolchain first and needs Node 20.19+ or 22.12+ (Vite 8's requirement).
The release branch has the package built (.github/workflows/release.yml commits it on every push to main, tagged build-<sha>), so installing it runs no build and needs no toolchain:
npm install github:haxiomic/devui#release # the latest
npm install github:haxiomic/devui#build-<sha> # a build of that commit of main (12 hex digits)
A build is made of main's latest commit once it passes the tests, so a commit followed quickly by another may not get its own tag. A built package says which commit it was made from, once in the console as the first GUI starts (devui 1a2b3c4, built 2026-10-29 18:30), and as import { build } from '@haxiomic/devui': a stale build shows.
Then use it from your bundler (esbuild, Vite, …). three and react are optional peer dependencies: three for @haxiomic/devui/three (and its noop and devui variants), react for @haxiomic/devui/react.
Adding controls
| Call | What you get |
|---|---|
gui.add(obj, 'key', hints?) | One property, with type, range and view inferred. Writes go to obj.key; objects are mutated in place |
gui.add(obj, hints?) | A whole object: an extension inspector (e.g. a three.js material), else every inferable property |
gui.params({...}) | Declares parameters and returns a live object. Entries are plain values or { value, ...hints } |
gui.folder('A/B', { collapsed }) | Nested folders. Idempotent, so calling it again returns the same folder |
gui.button('Reset', fn, { key: 'r' }) | A button, with an optional hotkey |
gui.monitor(obj, 'key', { view: 'graph' }) | A read-only readout or a graph, polled each frame. Three or more numbers in a row read as a grid of cells, each its value over its label |
gui.plot('Frame', { unit: 'ms' }) then .push(v) or .push({ cpu, gpu }) | A live line plot. Series are created on demand |
gui.texture('hdr', source) | A texture view: thumbnail plus inspector (see below) |
gui.perf({ gl }) | A Performance folder: FPS, frame time, 1% lows, JS heap, GPU time (see below) |
Calling add again with the same path (folder + key, or label/id) returns the same control and rebinds it. HMR and re-run setup code never duplicate controls. If the object is new, your tweaks are re-applied to it.
Inference. It's deliberately conservative:
- Numbers get a range only from names it's confident about:
roughness/opacity→ 0–1,count→ integer ≥ 0,ior,fov,exposure≥ 0, and so on. Otherwise you get a draggable field whose sensitivity scales with the value's magnitude. - A number in an angle unit (
unit: 'rad','deg'or'°'; by name,fovand a hue in degrees) gets the angle knob, as on a clock (see Curves and shapes). - Strings that parse as CSS colours become colour pickers.
- An array of CSS colours becomes a palette, and a
ramp()a colour ramp (see Colour). {x,y,z},[n,n,n]and typed arrays become vectors;{r,g,b}becomes a colour. A unit 3-vector named like a direction (lightDir,sunDirection,normal,axis) gets a direction gizmo.- A
curve()becomes a curve editor (see Curves and shapes). - Functions become buttons.
{ value }wrappers (uniforms) are unwrapped.
Hover a label to see why a control was inferred the way it was.
Ranges. A range given at both ends ({ value: 30, min: 0, max: 50 }, lil-gui's add(o, 'x', 0, 50)) is a slider's, not a wall: dragging stops at its ends, but push on and the value goes past (the drag readout says push on to pass the end, then past its range). A value typed, stepped or pasted past it is kept too. Past its range, the field turns orange with the end it passed marked. A limit holds: one end alone (min: 0), a range beside a soft one, or strict: true (a field of view, say). A row's menu sets its range: Range… gives a line under the row with its ends, Widen the range to the value fits it to a value past it, and Reset the range gives back the code's. A range set so is saved with the values (autosaved, in Copy values JSON, a linked file, save()), goes with Copy all as code as the control's min and max, and control.setRange({ min, max }) sets one from code.
Hints override inference: min, max (the range: see below), softMin, softMax (a slider range that's never a limit), strict (min and max are limits), step, precision, integer, scale: 'log', unit ('rad' shows degrees), options (array or record), color: { alpha, hdr, space: 'linear-srgb', scale: 255, gamut: 'p3' }, orientation: { format, order, forward }, view, label, description, id (its name in the path; by default the label, then the key), order (its place in the folder), readonly, hidden, disabled, listen: false (don't poll the property), multiline (a text area), persist, key (button hotkey).
Views (view: '…') for when the inferred one isn't what you want: numbers take slider, number (a field without the fill) or knob; 2-vectors pad; unit 3-vectors direction; 3- and 4-vectors orientation (Euler angles or a quaternion); curves curve or easing; strings text or textarea; options segmented or select; booleans toggle; monitors graph. Extensions add their own.
Control handles chain: .onChange(fn), .onFinishChange(fn), .hints({ max: 2 }), .reset(), .show(visible?)/.hide() and .enable(enabled?)/.disable() (so .show(advanced) toggles), .setLabel(text) (the path stays), .disposeWith(material | abortSignal), .dispose(). onChange runs for every change but the app's own writes:
- What runs it: the panel (a remote one too),
set()from code, presets, undo, A/B compare, a timeline, an audio band or MIDI, and loading values (gui.load(), a linked file that changed). A saved value restored when the control is added runs it once. - What doesn't: the app's own writes to the property, which the panel shows by polling, as in lil-gui. To change a value from code and run its handlers, call
set(). onChange(fn, { external: true })includes the app's writes, as polling notices them: while the row is on screen (every row while a remote panel is connected), never withlisten: false. So not every write, and one made while the row was hidden is seen when it shows.- Work that's slow to start (a load, a rebuild) belongs in
onFinishChange:onChangeruns on every step of a drag, a timeline or an audio band. While a band drives a control its changes aren't finished (as a drag's aren't):onFinishChangeruns once, when it lets go.gui.on('change', e => …)reports every change with itssource(user,code,external,load,preset,undo,compare,timeline,driver). See Setting up for the other events.