The idea
One value, not two.
A GUI that binds to a plain object has to invent a controller: something that owns the value, so it can tell the widget when you change it from code. That controller then becomes the only way to reach the value, and every feature — presets, links, undo, randomising — has to be built through it.
const c = gui.add(obj, "count", 10, 2000); c.onChange(v => rebuild()); obj.count = 800; c.updateDisplay(); // ...and you must remember this
gui.addSlider("Count", count, 10, 2000, 10);
effect(() => rebuild(count()));
count.set(800); // the widget already moved
Because the value is the shared thing, everything else is written against signals rather than against the GUI. A preset is a batch of writes. A shareable link is those values serialised. A randomiser writes new ones. The panel finds out the same way your sketch does.
What it is made of
Three pieces, each usable on its own.
The signals layer has no DOM dependency and runs in node. The panel is built on it. The scheduler sits between them and decides when work happens.
Owns the state
signal, computed, effect, batch,
tweened. No imports and no DOM, so it runs in a worker, in node, or in a
sketch with no panel at all.
Owns the panel
Twenty-one controls, tabs, folding sections, presets and persistence. Every
add* hands back a handle on the row it built.
Owns the timing
A queue your render loop flushes where it likes, so reactive work lands in a defined place in the frame instead of racing it.
Part one · state
A signal is a value you can subscribe to.
Reading a signal inside an effect subscribes that effect — automatically,
every time it runs. There is no dependency array to declare, and none to forget.
import { signal, computed, effect } from "guspira/reactive";
const width = signal(4);
const height = signal(3);
// derived — recomputed when either input changes
const area = computed(() => width() * height());
// runs now, and again on every change to what it read
effect(() => {
console.log(`${width()} x ${height()} = ${area()}`);
});
width.set(10); // the effect re-runs, once
- Dependencies are discovered, not declared. Re-discovered on every run, so a branch that stops reading a signal stops re-running for it.
-
Writing the same value costs nothing. Signals compare before notifying, arrays
element-wise — a range slider handing back a fresh
[min, max]on every pointer move only wakes effects when the numbers moved. - One write, one pass. An effect reading both a signal and something derived from it runs once, not once per level, and never sees a half-updated value.
- Effects can clean up. Return a function and it runs before the next run and on stop — the listener you added, the timer you started.
- It fails loudly. An effect that throws is reported and skipped rather than taking the rest of the pass with it; one that writes what it reads is named, not left to overflow the stack.
Part two · the panel
Every control is bound to one of them.
That one decision removes the layer other GUI libraries spend their code on: no
controller object holding a second value, no listen(), no
updateDisplay() after you change something yourself.
const params = {
count: signal(28),
shape: signal("ring"),
tint: signal("#ffb347"),
};
const gui = new GUI("Sketch");
gui.addSlider("Count", params.count, 1, 200, 1);
gui.addSelect("Shape", params.shape, ["ring", "grid"]);
gui.addColor("Tint", params.tint);
// the sketch reads the same signals — that is the whole wiring
effect(() => draw(params));
What's in the box
Everything a sketch panel ends up needing.
Twenty-one controls
Sliders, dual ranges, vectors, XY pads, graphs, segmented choices, selects, colours, text, text areas, numbers, monitors, buttons, file pickers.
Measures itself
Counters after rStats — timers, framerate, and a stacked graph of where a frame went.
Tabs and folding sections
Grouped, able to hide themselves from a predicate, and remembered between reloads.
Presets and persistence
A typed store that survives a reload, rejects junk, exports to JSON and travels in a link.
Themed by variables
Every colour and metric is a CSS custom property, so a theme is an override, not a fork.
Randomize anything
Click a label to reroll it, a button or a key for the panel — from a source you can seed.
Reachable without a mouse
Folding, tabs, rerolling and typing a value all take keys. bindKey adds your own.
Render on demand
Make render() an effect on a queue you flush, and an idle sketch draws nothing.
Values that travel
tweened eases instead of jumping, so a preset morphs into place. Eight easings ship.
Custom controls
createRow, bind, onDestroy and a .gui-control class — yours behaves like a built-in.
A slider you can use alone
<range-slider> is a form-associated custom element — single or dual, keyboard and all.
Tested, not just shipped
Node suites for the core, browser assertions driving real drags and keys, and every page loaded and checked.
Panels that change shape
Add and destroy rows while the sketch runs — the handle takes the row's effects with it.
Reference
Every control, running.
Each one takes a label and a signal, then whatever that control needs. These are not pictures — every row below is a real control bound to a real signal, built by the line underneath it.
Any control also takes a trailing options object. The two state options come in
pairs — disabled / disabledWhen and visible /
visibleWhen — and both names take either a boolean for a state that never
changes or a predicate for one that does. A predicate re-evaluates whenever anything
it reads changes, so the rule lives in one place instead of in every handler that
might affect it.
- onChange — user input only; a write from code does not fire it.
- disabled / disabledWhen — greys the row out and stops it responding.
- visible / visibleWhen — removes the row entirely while false.
- randomizable: false — keeps a value out of rerolls.
- title — the tooltip on the label.
gui.addSlider("ISO", params.iso, 50, 6400, 50, {
onChange: () => rebuild(),
disabledWhen: () => params.mode() === "auto", // a rule
title: "Only in manual mode",
});
gui.addSlider("Build", params.build, 0, 9, 1, {
disabled: true, // a fact
});
gui.addSection("Manual", {
visibleWhen: () => params.mode() === "manual",
});
And around the edges: addTab and addSection to arrange them,
into to add to one later, addPresets for the whole
save/load/export panel, createParams and createPresetStore for
the state behind it, bindKey for shortcuts, and setRandomSource
when a reroll needs to be reproducible.
Getting it
One install, no dependencies.
The whole appearance of the panel lives in one stylesheet, so bring that along with the modules — without it the controls all work and look like nothing at all.
Import from guspira to get everything at once, or from a subpath to take
only the part you need. What npm installs is the source rather than a bundle, so your
tooling sees the modules as they were written and drops the ones you never import.
- guspira/reactive — the signals alone: no DOM, runs in a worker or in node
- guspira/gui — the panel and every control on it
- guspira/params · guspira/presets — state that survives a reload and travels in a link
- guspira/color · guspira/format · guspira/random · guspira/keyboard
- guspira/range-slider — the two-handled element on its own
npm install guspira
import GUI from "guspira/gui";
import { signal, effect } from "guspira/reactive";
import "guspira/css/gui.css";
const count = signal(400);
const gui = new GUI("Sketch");
gui.addSlider("Count", count, 10, 2000, 10);
effect(() => rebuild(count()));
And for a page with no build step at all: the same API arrives on a Guspira
global, straight from a CDN. Pin the version in anything meant to keep working — an
unpinned URL follows whatever latest becomes.
<link rel="stylesheet" href="https://unpkg.com/guspira@0.1.0/dist/guspira.min.css">
<script src="https://unpkg.com/guspira@0.1.0/dist/guspira.iife.min.js"></script>
<script>
const { GUI, signal } = Guspira;
const count = signal(400);
const gui = new GUI("Sketch");
gui.addSlider("Count", count, 10, 2000, 10);
</script>
<script type="module">
import { GUI, signal } from "https://cdn.jsdelivr.net/npm/guspira@0.1.0/dist/guspira.min.js";
</script>
See it work
Demos
One page per part of the toolkit, each with its panel beside it and its source in
demo/<name>.js, which is meant to be read.
Start here
Warp field
A domain-warped noise field on the GPU — every control is a uniform, so the whole image answers on the next frame.
Controls
Every control the panel ships, each beside the signal it is bound to.
Options
Rows that disable and hide themselves, and what does not count as user input.
Building a panel
Tabs & sections
Grouping, folding, conditional sections, and a layout the panel remembers.
Controllers
Adding and destroying rows while the sketch runs, without leaking their effects.
Extending
A vector row and an XY pad built from scratch, behaving like built-ins.
Theming
Restyle the panel live — every colour and metric is a custom property.
State, time and data
Reactivity
Computed values, batching and untracked reads — with every effect run counted.
Values that travel
The panel shows where you put a value; your code reads where it currently is.
Rendering
render() as an effect: draw only when something changed, where the loop says.
Params & persistence
A typed store that survives reloads, rejects junk and travels in a query string.
Presets
Built-in looks, saved looks, and the same JSON going out to a file and back.
Input