Guspira · a reactive GUI toolkit for sketches

Controls that are the state.

Every control is bound to a signal, and the signal is the value. Move the slider and it updates. Set it from code and the slider moves. There is no third thing keeping the two in step — which is most of what a GUI library usually is.

Browse the demos ↓ The full sketch Install GitHub Tests

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.

with a controller
const c = gui.add(obj, "count", 10, 2000);
c.onChange(v => rebuild());

obj.count = 800;
c.updateDisplay();   // ...and you must remember this
with a signal
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.

reactive.js

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.

gui.js

Owns the panel

Twenty-one controls, tabs, folding sections, presets and persistence. Every add* hands back a handle on the row it built.

createScheduler

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.

sketch.js
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.

panel.js
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.
options.js
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
install
npm install guspira
sketch.js
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.

index.html
<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>
…or as a module, no global
<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

Building a panel

State, time and data

Input