I've dangled this one in front of you twice now. Back when we built our own little CC framework I threw together a scrappy buildPanel that walked a params object and spat out a slider for each number, and I said - almost apologising - that proper control panels get a whole episode of their own later. Then last time, while we were hunting down slow frames, I muttered that poking at console.log is a pain and wouldn't it be nicer to have real knobs and readouts, a little instrument panel for your sketch. Well. Allez, this is that episode. Today we turn our sketches into instruments you can actually play - with sliders, colour pickers, buttons, presets, the works :-).
And I want to frame why this matters before we touch a single knob, because it's easy to dismiss a GUI as decoration. It isn't. The moment you can change a value without editing code, something changes in your relationship to the work. When tuning means "edit the number, save the file, wait for the reload, squint, repeat", you tune maybe ten times before you get bored and settle. When tuning means "drag a slider and watch it move", you tune two hundred times in the same afternoon, and you find things you would never have typed. The knob isn't there to look pretty. It's there to shorten the loop between "I wonder what if" and "oh, that". Shorten that loop enough and you stop engineering the sketch and start playing it.
Before we grab a library, let's build a real one ourselves, properly this time. Not because you'll ship this - you won't - but because a GUI library feels like magic until you've written one, and then it feels obvious forever. Remember the whole idea from our framework episode: a params object is just data. Every knob we could ever want is really just "point a control at one property of that object and keep them in sync". That's the entire secret.
// our params from the framework episode - just a plain object of numbers.
const params = {
count: 200, // how many particles
speed: 1.3, // drift speed
radius: 30, // size of each dot
hue: 330, // base colour
};
Last time our buildPanel was four lines and had no labels, no value readout, no sensible range - it guessed max = value * 3 and hoped. Let's do it honestly now. A good number-control needs a name, a min, a max, a step, and it needs to show you the current value as you drag. So instead of walking the object blindly, we'll describe each knob a little.
// a proper slider: label on the left, the slider, and a live value readout on the right.
// it MUTATES params[key] directly and calls onChange so the sketch redraws.
function addSlider(parent, params, key, { min, max, step = 0.01 }) {
const row = document.createElement("div");
row.style.display = "flex";
row.style.gap = "8px";
row.style.alignItems = "center";
const label = document.createElement("label");
label.textContent = key;
label.style.width = "70px";
const slider = document.createElement("input");
slider.type = "range";
slider.min = min; slider.max = max; slider.step = step;
slider.value = params[key];
const readout = document.createElement("span"); // shows the number as you drag
readout.textContent = params[key];
readout.style.width = "50px";
slider.addEventListener("input", () => {
params[key] = parseFloat(slider.value); // keep the data in sync with the knob
readout.textContent = params[key]; // keep the readout in sync too
params.onChange && params.onChange(); // tell the sketch something moved
});
row.append(label, slider, readout);
parent.append(row);
}
See how little there is to it? A control is just an <input> glued to one property, with a listener that writes the property and pokes the sketch. Everything a fancy GUI library does is a variation on those eight lines. Now let's wire a few sliders up to a live sketch, using the CC.sketch bootstrap from our framework.
// wire three knobs to a running sketch. drag any of them and the art reacts instantly.
const panel = document.querySelector("#panel");
addSlider(panel, params, "count", { min: 10, max: 2000, step: 1 });
addSlider(panel, params, "speed", { min: 0, max: 5 });
addSlider(panel, params, "radius", { min: 1, max: 80, step: 1 });
CC.sketch((ctx, { t, size }) => {
ctx.fillStyle = "#111";
ctx.fillRect(0, 0, size, size);
for (let i = 0; i < params.count; i++) { // reads params LIVE, every frame
const x = (i * 97) % size;
const y = size / 2 + Math.sin(t * params.speed + i) * 200;
ctx.fillStyle = `hsl(${params.hue}, 70%, 60%)`;
ctx.beginPath();
ctx.arc(x, y, params.radius, 0, CC.TAU);
ctx.fill();
}
});
The crucial thing to notice: the sketch reads params.count, params.speed, params.radius fresh every frame. So the instant a slider writes a new value into params, the very next frame draws with it. There's no wiring between the knob and the draw loop beyond the shared object. That loose coupling - control writes data, sketch reads data, nobody talks directly - is the whole architecture, and it scales to a hundred knobs without getting messier.
Right, you understand the trick now, so here's my honest advice: build the toy above once to get it, then stop, and use a proper library for real work. This is exactly the rule of three from our framework episode - you've now written a scrappy panel twice (last episode and this one), the pattern has proven itself, and re-implementing colour pickers and dropdowns by hand from scratch is precisely the "designing the kitchen instead of cooking" trap. Somebody already built a beautiful kitchen. Use theirs.
There are three names worth knowing, and they're basically one family tree. dat.GUI is the granddad - it's the little control panel you've seen in the corner of a thousand WebGL demos, made by the Google Data Arts team. It still works but it's no longer maintained. lil-gui is its spiritual successor - same API almost exactly, modern, tiny, actively kept up. And Tweakpane is the fancy cousin: prettier, more control types, and - the bit I love - it can draw live graphs, which is perfect for the FPS readout we wanted last episode. My default is lil-gui for quick knobs, Tweakpane when I want it to look nice or need a monitor. Let me show you lil-gui first, because if you learn one, you basically know all three.
// lil-gui: point it at your params object and it builds the whole panel for you.
// (loaded via or `import GUI from "lil-gui"`)
const gui = new GUI();
gui.add(params, "count", 10, 2000, 1); // (object, key, min, max, step) - same idea as ours!
gui.add(params, "speed", 0, 5, 0.01);
gui.add(params, "radius", 1, 80, 1);
That's it. Three lines and you have a slick draggable panel, docked top-right, with labels and live readouts and everything our hand-rolled version needed twenty lines for. And look at the shape of gui.add - (object, key, min, max, step). It's our addSlider, same arguments, same "point at one property" idea. You already understood it before you saw it. That's the whole reason we built the toy first.
Sliders are the workhorse, but a good control panel speaks in the right shape for each value. lil-gui (and Tweakpane, and dat.GUI) picks the control automatically based on what you give it, and you can nudge it. Here's the vocabulary.
// lil-gui infers the right control from the value and the arguments you pass.
const params = {
radius: 30, // a number...
running: true, // a boolean...
mode: "spiral", // a string from a set...
colour: "#e91e63", // a colour...
regenerate: () => reseed(), // a function...
};
gui.add(params, "radius", 1, 80, 1); // number + range -> a SLIDER
gui.add(params, "running"); // a boolean -> a CHECKBOX
gui.add(params, "mode", ["spiral", "grid", "wave"]); // array -> a DROPDOWN
gui.addColor(params, "colour"); // addColor -> a COLOUR PICKER
gui.add(params, "regenerate"); // a function -> a BUTTON you can click
This is lovely because it maps the type of the value to the right way to touch it. A colour should be a colour picker, not three raw number sliders (though you can have those too if you want). A choice between named modes should be a dropdown, not a number you have to remember means "2 = wave". And a function becomes a button - so "regenerate", "save frame", "clear" all become clickable actions right in the panel. That button trick is genuinly underused. Any one-off action in your sketch - reseed the randomness (episode 24!), export a PNG (episode 20!), clear the canvas - can just be a method on params and lil-gui turns it into a button for free.
So far every control writes into the sketch. But half of a good instrument panel is the other direction - reading values out, watching numbers the sketch produces. This is exactly the FPS meter itch from last episode. We had fps as a variable and dumped it to the corner of the canvas by hand; wouldn't it be nicer sitting in the panel? Both lil-gui and Tweakpane can do this, but Tweakpane does it best because it can draw a rolling graph.
// Tweakpane can MONITOR a value - read it continuously and even graph it over time.
const pane = new Tweakpane.Pane();
const readouts = { fps: 0, particles: 0 };
pane.addMonitor(readouts, "fps", { view: "graph", min: 0, max: 60 }); // a live line graph!
pane.addMonitor(readouts, "particles"); // just a number readout
// in your draw loop, keep the monitored object updated:
function draw(ctx, info) {
readouts.fps = measureFPS(info); // the FPS counter from last episode
readouts.particles = pool.length; // whatever you want to watch
// ... draw ...
}
Now you've got a live FPS graph in the corner of your screen, updating every frame, and you can see the stutter arrive as a dip in the line rather than feeling it in your gut. Pair that with the profiling instincts from last episode and you've built yourself a tiny cockpit: knobs to change the art on the left, gauges telling you what it costs on the right. That combination - control and readout in one panel - is what separates a toy from a tool.
Here's where it goes from "fun to fiddle" to "genuinely powerful", and it's my favourite part. You've been dragging sliders for twenty minutes, and suddenly the sketch looks perfect - the count, the speed, the hue, all landed in some magic combination. Then you nudge one slider to see if it gets better, it gets worse, and... you can't get back. You lost it. This has broken my heart more times than I can count. The fix is presets: because params is just data, a preset is just a snapshot of that object. Save it, and you can jump back to that exact magic moment any time.
// a preset is nothing more than a copy of the params object at a good moment.
// structuredClone makes a clean deep copy so later edits don't mutate the saved one.
const presets = {};
function savePreset(name) {
presets[name] = structuredClone(params); // freeze the current settings under a name
console.log(`saved preset "${name}"`, presets[name]);
}
function loadPreset(name) {
Object.assign(params, presets[name]); // copy the saved values back INTO params
gui.controllers.forEach((c) => c.updateDisplay()); // make the sliders jump to match
}
Two things worth pausing on. First, Object.assign(params, presets[name]) copies the saved values into the existing params object rather than replacing it - that matters, because your sketch and your sliders all hold a reference to that one object, and we want them all to see the change. Second, c.updateDisplay() - when you load a preset, the data changes but the sliders don't know yet, so you have to tell them to re-read and jump to their new positions. Forget that line and the art changes while the knobs stay put, which is deeply confusing. Ask me how I know :-).
Presets living in a JavaScript object vanish the second you refresh the page, which is useless. Let's make them persist. The browser gives us localStorage - a tiny key-value store that survives reloads, right there for free. And since a preset is just data, saving it is one JSON.stringify away.
// persist presets to localStorage so they survive a refresh (or closing the tab).
function persistPresets() {
localStorage.setItem("cc-presets", JSON.stringify(presets)); // object -> string -> stored
}
function restorePresets() {
const raw = localStorage.getItem("cc-presets");
if (raw) Object.assign(presets, JSON.parse(raw)); // string -> object -> back in memory
}
restorePresets(); // call this once on startup, and your saved looks are waiting for you
Now your good moments outlive the tab. You come back tomorrow, hit "load", and there's yesterday's sunset palette exactly as you left it. This is the same JSON.stringify / JSON.parse round-trip we used for parsing data files back in episode 81 - it turns out "turn this object into a string and back" is one of those tricks you use forever, in a hundred unrelated places. Data is data.
One more step and it gets genuinely magic. If a preset is a JSON string, then it's just text - which means you can copy it, paste it in a message, drop it in a comment, and someone else can load your exact settings. Suddenly your tool is collaborative. Let me add a "copy settings" and "paste settings" pair.
// export the current settings as a string you can paste anywhere...
function copySettings() {
const text = JSON.stringify(params, null, 2); // pretty-printed so it's human-readable
navigator.clipboard.writeText(text); // straight to the clipboard
console.log("settings copied - paste them to a friend!");
}
// ...and load settings someone pasted back in.
function importSettings(text) {
const incoming = JSON.parse(text);
Object.assign(params, incoming);
gui.controllers.forEach((c) => c.updateDisplay()); // sync the knobs, same as before
}
Think about what that unlocks. You make a generative piece, you find three gorgeous presets, and instead of screenshots you share the actual settings - your friend loads them, sees the exact same art, and then drags a slider to make it theirs. That's a completely different thing from sharing an image. You're not sharing the output, you're sharing the instrument, tuned. For the really keen, you can even shove the settings into the URL itself so a link is a preset - that's how sites like fxhash and ShaderToy let you share a specific configuration, and it's the same JSON.stringify living in location.hash.
Once you're having fun, panels balloon. Twenty knobs in one flat list is a mess nobody can read. Every library has the same answer: folders. Group related knobs, give the group a name, collapse the ones you're not touching. It's the same "gather related things under one roof" instinct we keep meeting.
// folders keep a big panel readable - group knobs, collapse what you're not using.
const motion = gui.addFolder("Motion");
motion.add(params, "speed", 0, 5);
motion.add(params, "count", 10, 2000, 1);
const look = gui.addFolder("Appearance");
look.addColor(params, "colour");
look.add(params, "radius", 1, 80, 1);
look.add(params, "hue", 0, 360, 1);
look.close(); // start this folder collapsed - open it only when you're tuning colour
It's a small thing but it changes how a tool feels to use. A wall of thirty identical sliders is intimidating; three tidy folders labelled "Motion", "Appearance", "Export" reads like a menu. And here's the quiet lesson hiding in that: the organisation of your controls is itself a design decision. How you group the knobs teaches whoever uses your tool how you think the piece is structured. A good panel isn't just functional, it's a little map of the art's soul.
I want to close on the mindset, because that's the real shift this episode is about, and it's bigger than sliders. When you add a GUI to a sketch, you quietly stop making a piece of art and start making a machine that makes art. That's a genuinly different job, and it comes with a different question. Instead of "does this look good?", you start asking "what's the range of good things this can make, and are the knobs pointed at the interesting parts of that range?"
That question is sneaky-deep. A slider from 0 to 1000 where only 40-to-60 looks any good is a bad slider - you've handed someone a knob that's boring 96% of its travel. Part of building a good tool is choosing ranges so that every position is worth visiting, so that dragging always rewards you. When I build a tool for myself, half the work is tuning the tools, not the art - moving a slider's min and max until there are no dead zones, until every drag lands somewhere you'd want to keep. You're designing an experience of exploration, not a single output.
And the deepest version of it: build the tool as if you're handing it to someone who isn't you. A non-coder friend, a version of yourself in six months who's forgotten how it all works. That imagined stranger is a brilliant editor. They can't read the code, so the knobs have to be honest. They don't know your magic numbers, so the labels have to be clear. Building for that stranger forces every good habit - sensible ranges, clear names, presets that capture the good stuff. See where this is going? Once your sketch is a real instrument with honest knobs, you're one short step from it being a system other people can compose with. That's a door, and it's the one we walk through next.
Here's the brief, and it's a keeper because the result is a tool you'll genuinely use. Take one sketch you love - a particle system, a flow field, the boids from episode 50, anything with numbers you've been hand-editing - and give it a proper panel. Start by pulling every magic number out into a params object (if you did the framework episode's exercise, you're already there). Then wire lil-gui or Tweakpane to it: a slider for every number, a colour picker for every colour, a dropdown for any mode, and at least one button for an action like reseed or save-frame.
Then do the two things that make it a tool and not a toy. First, add presets - a "save" and "load" pair, persisted to localStorage so they survive a refresh. Find three looks you love and save them. Second, and this is the real assignment: go through every slider and fix its range. Drag each one to both extremes and ask "is any part of this travel dead and boring?" If the bottom third does nothing interesting, raise the min. Tune the knobs, not just the art, until every drag of every slider lands somewhere you'd be happy to keep. When you're done, send someone your three presets as pasted text and watch them load your exact art. That last bit - someone else playing your instrument - is the whole point, and it feels great :-).
Giving a sketch honest knobs changes what it even is. It stops being a fixed picture and becomes a little space of possibilities that you - or anyone - can wander around inside. And once you're thinking that way, in terms of ranges and systems rather than single outputs, a much bigger idea comes knocking: what if the whole thing was designed, from the ground up, as a system of rules and relationships that generates a family of related pieces, all sharing a look but each one different? A brand identity that draws itself, a poster series that's really one machine, a thousand cousins from one set of knobs. That's a real discipline with real working designers behind it, and it's exactly where we go next. And a little further out, this instrument-panel thinking meets its natural home - live, in front of people, tweaking the knobs as the audience watches. Both of those grow straight out of the panel you're about to build. So build it properly - fix those slider ranges, save those presets - because from here on we stop making pictures and start making machines that make pictures :-).
params is just data. A control is only an <input> glued to one property, with a listener that writes the property and pokes the sketch. Build one from scratch once (label + slider + live readout) and every GUI library stops being magic forevergui.add(params, "count", 10, 2000, 1) is your addSlider, same argumentsaddColor -> colour picker, and a function -> a clickable button (perfect for reseed, save-frame, clear)addMonitor with view: "graph" turns last episode's FPS meter into a live graph - knobs on the left, gauges on the right, a proper little cockpitstructuredClone(params). Save the magic moment, Object.assign it back to return - and call updateDisplay() so the knobs jump to matchlocalStorage (one JSON.stringify) so they survive a reload, and because they're just text you can copy-paste or URL-share a preset - you're sharing the instrument tuned, not just an imageSo that's the promise I made you two episodes ago, finally paid off: our scrappy little buildPanel grew up into a real instrument panel, and our sketches grew up with it. The wild part is how little stood between "edit the number and reload" and "drag a knob and play" - a shared data object, an <input> or two, and the willingness to think of your art as a machine instead of a picture. Give one sketch honest knobs and three saved presets this week, tune the ranges until every drag is a joy, and then hand it to a friend. Watching someone else play an instrument you built is one of the best feelings in this whole craft. Go make one :-).
Sallukes! Thanks for reading.
X