devuiGuideDemosAPIGitHub

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

CallWhat 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:

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: