All studies

2026-09 · Aesthetic · Figma · GitHub

Struck tones

A UI sound palette written as a token block. Four sounds declared as a tuning, a voice, a strike and a room, synthesised from those numbers rather than played from files — the reverb included, whose impulse response is generated when you arm it. Each faceplate below is silent until you do: by its own switch, or all four at once with Sound at the top of the page.

Struck — the set as declared

Struck tones

Tap

one note

528 Hz

16 ms rise · 820 ms fall

Tuning
528 Hz
Voice
sine 2× 3×
Strike
16/820 ms
Room
1.2 s · 10%

Hushed — only the strike and the room moved: 210 ms rise, no click, a longer tail

Struck tones

Tap

one note

528 Hz

16 ms rise · 820 ms fall

Tuning
528 Hz
Voice
sine 2× 3×
Strike
16/820 ms
Room
1.2 s · 10%

Close — only the tuning moved: a 396 Hz root and narrower steps, the same voice

Struck tones

Tap

one note

528 Hz

16 ms rise · 820 ms fall

Tuning
528 Hz
Voice
sine 2× 3×
Strike
16/820 ms
Room
1.2 s · 10%

Play it

The same component a fourth time, with a module of controls wired to it. Every control writes one token onto the faceplate’s own element and nothing reaches inside it, so the sound, the screen, the drawings and the timing all follow — the study’s whole argument, made playable. Arm the sound and play the keys; with a control focused, A, S, D and F play them too. Drag a knob up or right, hold Shift to go fine, and double-click it to put it back.

Struck tones

Tap

one note

528 Hz

16 ms rise · 820 ms fall

Tuning
528 Hz
Voice
sine 2× 3×
Strike
16/820 ms
Room
1.2 s · 10%

Token controls

Tuning

Voice

Wave

Strike

Room

Patch

/* every token at its default */

The faceplate

The set is dressed as what it is. An instrument’s panel is already a sheet of labelled parameters, each drawn to be read at a glance — which is what a token block wants to be — so the component takes the grammar of a Moog or a Korg panel and gives it a green skin: modules lettered with a rule, a screen let into the plate, keys with a lip under them, and deep wood at the ends.

01 · Colour

Deep, so it can be printed on

A vivid green was the brief, and a vivid mid green cannot carry type in either ink: bone on #2e7d4f is 4.04:1 and near-black is 3.74:1, both under the floor. So the panel went deep, where the print measures 11.28:1 and the soft print 6.46:1, and the vivid green went where the light is — the screen, the LEDs, a key while it is lit.

  • Panel#1a3024print 11.28:1
  • Print#ece6d3
  • Soft print#a9b3a06.46:1 on panel
  • Glass#0b150f
  • Phosphor#a6f57f14.17:1 on glass
  • Walnut#5a3920
  • Mid green#2e7d4f4.04 bone, 3.74 ink · nothing prints

02 · Phosphor

One tube for each setting

Each variant has its own phosphor, the way a display tube does: green for the set as declared, a cooler mint for hushed, amber for close. It is the one colour a variant changes, so the three read as one instrument with three settings rather than as three products.

  • Struck
  • Hushed
  • Close

03 · Type

Lettered, not typed

The paper version set everything in a mono and read as a typewriter. The panel is lettered in a condensed grotesk, Barlow Semi Condensed, spaced wide in capitals the way panel print is; the mono is kept for the screen, where a fixed-width figure is what a display shows.

Barlow Semi Condensed for the print and the figures; JetBrains Mono for the screen’s readouts.

04 · Material

Wood, drawn in the stylesheet

The cheeks are walnut, after Moog’s and Korg’s: deep, dark wood either side of the panel, the one material those instruments share, here set against green. The grain is an SVG turbulence filter written into component.css as a data URI — stretched along the grain, then coloured by a matrix — so there is no image to fetch, and the wood is a token like everything else.

The grain, generated: fractal noise at 0.6 across and 0.011 along.

05 · Parts

Four parts, one job each

Screen
What was just struck, drawn from the numbers it was played from.
Keys
The four sounds, each drawn as the gesture it makes.
Sheet
The four token groups, each drawn from its own tokens.
Switch
The way out of silence, and the gesture audio needs.

How the set is built

Everything on the faceplate — every sound, every drawing, every strike — is read from one block of custom properties at the top of component.css. The script reads it with getComputedStyle and hands the numbers to Web Audio and to the screen’s shader; the stylesheet reads the same numbers with calc(). So there is one place to change anything, and a change moves the sound and the picture together.

01 · Tuning

What the set is pitched at

A sound here is a gesture: the root, then a step up or down a moment later. Steps are semitones rather than hertz, so moving --root transposes the whole set and every interval keeps its meaning. The -t- tokens say how soon the second note falls, in multiples of --spread.

component.css
.struck-tones {
  --struck-tones-root: 528;     /* Hz, the tonic */
  --struck-tones-spread: 104;   /* ms, a gesture's step */

  --struck-tones-i-commit: 7;   /* up a fifth */
  --struck-tones-t-commit: 1;   /* a spread later */
  --struck-tones-i-alert: -1;   /* down a semitone */
  --struck-tones-t-alert: 0.55; /* sooner than any */
}

The script reads each number off the element and turns a step into a frequency the way a keyboard does: every semitone multiplies the pitch by the twelfth root of two. The keyboard on the Tuning module is that same axis, a fifth either side of the root, with an LED under the key each sound lands on.

component.js · abridged
var s = getComputedStyle(root);

function num(name, fallback) {
  var v = parseFloat(
    s.getPropertyValue('--struck-tones-' + name));
  return isFinite(v) ? v : fallback;
}

function hz(pal, step) {
  return pal.root * Math.pow(2, step / 12);
}
// 528 × 2^(7/12) ≈ 791 Hz: the commit's top note

02 · Voice

What one note is made of

Every note is several oscillators at once. The fundamental sounds twice, a few cents apart and panned left and right, so it drifts rather than sitting still; an octave above adds body and a twelfth adds shimmer. The overtones are whole-number multiples of the note, which is what lets them fuse into one warm tone instead of a bleep.

component.css
--struck-tones-timbre: sine;
--struck-tones-partial: 2;          /* the octave */
--struck-tones-partial-level: 0.22;
--struck-tones-shimmer: 3;          /* the twelfth */
--struck-tones-shimmer-level: 0.06;
--struck-tones-chorus: 7;           /* cents apart */

The Voice module draws the argument. One cycle of the waveform is wrapped six times around a circle, so the radius at each angle is the wave at that phase. A whole-number partial repeats exactly once a cycle, so the ring closes on itself: taken three times round, it is still one line. The 2.76 of a struck metal bar never repeats, and three times round is three different lines.

2× and 3× · three turns, one line

2.76× · three turns, three lines

The Voice module’s drawing, taken three times round.
component.js · abridged
for (var i = 0; i <= ROSETTE_POINTS; i++) {
  var a = (i / ROSETTE_POINTS) * Math.PI * 2;
  var v = wave(a * ROSETTE_CYCLES, partials);
  var r = 0.62 + 0.3 * (v / peak);
  // x = r sin a, y = -r cos a
}

03 · Strike

How a note arrives and leaves

The envelope is two ramps on a gain node: straight up over --attack, then an exponential fall over --decay. An exponential curve never reaches zero, so it lands just below hearing and stops there. The click is a layer of its own, a few milliseconds of filtered noise, because a sharper attack is not a click but a discontinuity.

component.css
--struck-tones-attack: 16;       /* ms to peak */
--struck-tones-decay: 820;       /* ms to silence */
--struck-tones-level: 0.15;      /* peak gain, 0–1 */
--struck-tones-click: 0.11;      /* the noise under it */
--struck-tones-click-tone: 2100; /* Hz, where that sits */
component.js · abridged
var g = gain.gain;
g.setValueAtTime(0.0001, t0);
g.linearRampToValueAtTime(level, t0 + attack);
g.exponentialRampToValueAtTime(0.0001, t0 + attack + fall);

04 · Room

What it is heard in

A lowpass over everything keeps the set out of frequencies an interface has no business in, and a small reverb puts it in a room. The reverb has no file either: its impulse response is noise under a decaying curve, generated from --air-size when the set is armed, with a one-pole filter over it so it reads as air rather than static.

It was 0.18 through 1.7 seconds, which on sounds this short is a hall. The Room module draws the note and the room’s tail on one time axis, and at those values the tail was twice the note. It is a room now.

component.css
--struck-tones-tone: 3400;    /* Hz, a lowpass on it all */
--struck-tones-air: 0.1;      /* how much is the room */
--struck-tones-air-size: 1.2; /* seconds of tail */
component.js · abridged
for (var i = 0; i < n; i++) {
  last = last * 0.72 + (Math.random() * 2 - 1) * 0.28;
  d[i] = last * Math.pow(1 - i / n, 2.6);
}

05 · Drawing

The same numbers, read by the stylesheet

Nothing on the faceplate is measured. The envelope divides its width with flex-grow, which takes a bare number, and --attack and --decay are bare numbers, so the drawing has exactly the proportions the sound has. The second note is the same shape in a second row, led in by a spacer as wide as its lag. Each key plots its gesture from its own two tokens, and each LED sits under its key by the same arithmetic.

component.css · abridged
.struck-tones__env-rise {
  flex: var(--struck-tones-attack) 1 0;
}
.struck-tones__env-fall {
  flex: var(--struck-tones-decay) 1 0;
}

/* the second note waits its --t of a --spread */
.struck-tones__env-note--second .struck-tones__env-lead {
  flex-grow: calc(var(--struck-tones-shown-at)
    * var(--struck-tones-spread));
}

/* a key's second head: up by its step */
--struck-tones-y2: clamp(
  var(--struck-tones-head) / 2,
  50% - var(--struck-tones-step)
      * var(--struck-tones-contour-span),
  100% - var(--struck-tones-head) / 2
);

/* an LED under its key, a fifth either side */
.struck-tones__led {
  left: calc((clamp(-7, var(--struck-tones-led-step), 7)
    + 7.5) / 15 * 100%);
}

06 · Screen

A shader on the same clock

The screen is a fragment shader, and it keeps no clock of its own. Each frame reads the currentTime of a CSS animation that starts with the strike, so the sweep stays in step with everything else, and when the index pauses every animation in a thumbnail the shader holds with it. The loop runs only while a strike is in flight; the rest of the time the screen is one still frame.

It draws the first note as its waveform, slowed twenty times so that each cycle is a stroke: how tightly the strokes pack is the pitch. The second note is a dashed outline with a tick for every cycle, so the two stay apart where they overlap. Behind both, the room is a haze of grain that runs past the right-hand edge, because the room rings past the end of the note. With no WebGL the CSS envelope underneath is what shows, and it is the same drawing.

component.js · abridged
var anim = clock();   // the trace's struck-tones-cool
if (anim && anim.currentTime !== null) {
  now = Number(anim.currentTime);  // ms since the strike
}
gl.uniform1f(u.u_now, now);
gl.uniform1f(u.u_cyc1,
  hz(pal, pal.steps.tap) / 1000 / pal.timebase);
component.js · GLSL · abridged
float env(float t) {
  if (t < u_att) return t / u_att;
  float u = (t - u_att) / u_dec;
  return exp(-4.4 * u) - exp(-4.4) * u;
}

float sig(float t) {
  return env(t) * wave(TAU * u_cyc1 * t);
}

07 · Motion

A strike lasts as long as its sound

A strike animates one registered number from 0 to 1 and back: up over --attack, down over --decay, the same two ramps the gain node makes. Multiplying by 1ms is all it takes to turn the audio’s bare numbers into times. The fall is eased along the four points the envelope is drawn with, and the key, the heads and the line all read that one number, so --hushed swells and lingers on screen the way it does in the ear.

component.css · abridged
@property --struck-tones-glow {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

--struck-tones-rise-time:
  calc(var(--struck-tones-attack) * 1ms);
--struck-tones-fall-time:
  calc(var(--struck-tones-decay) * 1ms);

.struck-tones__pad[data-hit] {
  animation-name: struck-tones-rise, struck-tones-fall;
  animation-duration: var(--struck-tones-rise-time),
    var(--struck-tones-fall-time);
  animation-delay: 0ms, var(--struck-tones-rise-time);
  animation-timing-function: linear,
    linear(0, 0.46 14%, 0.74 34%, 0.91 62%, 1);
}

08 · Retuning

Change it without opening the file

Which is the point of writing a sound set this way. Override a token on the component’s own element, from anywhere, and the sound, the read-out, the drawings, the screen and the timing all follow. --hushed and --close above are built exactly like this: token overrides and nothing else, down to the colour of the phosphor. So is every control under Play it, and the patch it prints is the stylesheet you would write.

your stylesheet
/* A softer set for a settings panel: down to A,
   a rounder strike, a third where the fifth was. */
.settings .struck-tones {
  --struck-tones-root: 440;
  --struck-tones-attack: 60;
  --struck-tones-decay: 1100;
  --struck-tones-i-commit: 4;
  --struck-tones-phosphor: #ffc56e;
}

Decisions

Type
Aesthetic

A sound palette for an interface, written as a token block and set as an instrument’s faceplate. Four sounds — a tap, a commit, a revert and an alert — are declared as a tuning, a voice, a strike and a room, and component.js reads those custom properties and builds the sounds out of oscillators at the moment a key is struck. There are no audio files in the folder and no request to make, the reverb included: its impulse response is generated from two numbers when the set is armed. Each key draws its sound as the gesture it is, the screen draws whichever was struck last, and a sheet along the bottom draws the four token groups the set is made from.

It ships silent. The arm switch in the corner is both the mute and the user gesture an AudioContext needs, so the component cannot be the reason a page makes a noise nobody asked for — and everything it says, it says visually too, because a sound nobody hears still has to deliver what it was carrying.

  1. A sound set is a palette, and a faceplate is its swatch sheet.

    Twenty-odd numbers in four groups — what it is tuned to, what one note is made of, how a note arrives and leaves, and what room it is heard in. component.js reads every one of them with getComputedStyle and hands them to the nodes, so nothing is written twice and the whole set moves when one value is overridden from outside. That is the test CLAUDE.md sets for a property block — re-theme it without opening the file — applied to something that is not a colour, and a .wav would fail it: a recorded sound is a decision you can replace but not edit.

    The sheet along the bottom draws the four groups the way an instrument’s panel letters its modules: a keyboard with an LED under each sound’s key, the voice wrapped around a circle, one note’s envelope, and the note against the room’s tail — each from its own tokens, with its values printed under it. It replaced a list of four figures that said the same things and showed none of them. The panel is a deep green between walnut cheeks, and deep because it has to be: a vivid mid green like #2e7d4f carries bone print at 4.04:1 and near-black at 3.74:1, so no text can go on it at all. At #1a3024 the print is 11.28:1 and the soft print 6.46:1, and the vivid green is spent where it can be afforded — the phosphor, the LEDs, the lit keys.

  2. A note is four layers, not a waveform.

    A sine fundamental sounded twice seven cents apart and panned against itself, so it drifts instead of sitting still; an octave above it at 0.22 for body; a twelfth above that at 0.06 for shimmer, decaying at 0.3 of the fundamental’s rate because every acoustic overtone dies before its note does; and 26ms of band-passed noise at 2.1kHz under the attack, which is the click. All of it through one 3.4kHz lowpass and then split between the dry signal and a small generated room, 0.1 of it through 1.2 seconds. The room was 0.18 through 1.7, which on sounds this short is a hall: drawn against the note on the Room module, the tail was twice the note’s length.

    The first version was one oscillator with a sharp envelope and it sounded like a 1980s sound chip for three separable reasons, each worth knowing on its own: a raw geometric wave with nothing rolled off; an inharmonic partial at 2.76× the fundamental, which is the ratio a struck metal bar has and an overtone belonging to no key, so it reads as a bleep however carefully it is enveloped; and a 4ms attack, which is not a click but a discontinuity. A click is a layer. An attack is a shape. Sharpening the second to get the first is what makes a set brittle. The Voice module draws the second reason: one cycle of the wave wrapped six times around a circle closes on itself only when every partial is a whole number, and at 2.76 no two petals match. It draws in whichever of the four oscillator shapes --timbre names, and so does the screen, so a square set grows square petals and the picture cannot go on showing a sine the oscillators have stopped playing.

  3. Direction carries the message, and nothing in the set beats.

    Up a fifth for a commit, down a fourth for a revert, a single note at the root for a plain acknowledgement, and a semitone down for the alert, played quickly. That last one used to sound its semitone against the root — two pitches 31 Hz apart, which is not slow beating but roughness, and genuinely unpleasant rather than merely urgent. Same interval, sounded after rather than with: nothing overlaps, so nothing beats, and what is left is the unease of stepping down onto a note that is nearly the tonic and is not. The urgency moved into the timing instead — it is the quickest gesture in the set, at 0.55 of a --spread where the others take a full one — and the timing is a token, --t-alert, rather than a constant in the script, so the keys and the screen can draw it. What a screen reader hears is composed from the steps too: it used to be written beside them, and --close went on announcing a fifth while it played a minor third.

  4. The picture is drawn from the audio’s own numbers, and so is the motion.

    flex-grow takes a bare number and --attack/--decay are bare numbers, so the rise and the fall of the drawn envelope divide the width in exactly the ratio the sound is made at — nothing measures anything. Each key draws its sound as a contour: a head per note, across by its --t, up or down by its step at a --contour-span per semitone from a dotted line at the root. It replaced a bar sized by the last note’s pitch, which could say how high but not which way or how soon: the alert’s semitone came out 1.4px shorter than the tap’s bar, and under --close all four bars matched. The screen draws the struck gesture on one axis for every sound — the slowest gesture in the set — with the second note the same shape as the first, drawn distinct over it, so the alert’s lands visibly sooner than the commit’s.

    A strike is one registered number, --glow, animated up over --attack and down over --decay — the gain node’s two ramps, the fall eased along the envelope drawing’s own four points — and the key, the heads and the line all read it. Multiplying the bare numbers by 1ms is the whole of the conversion, so component.css names no motion duration at all. The flash used to be a fixed 420ms and the playhead a fixed 800ms, which drew --hushed’s 1.7s note in under half its length. Reduced motion keeps the light and drops what travels: a strike still fades over its own timing, but the heads do not swell and the playhead does not cross.

    The screen’s shader keeps the same time rather than a time of its own: each frame reads the currentTime of a CSS animation that starts with the strike, so its sweep is in step with the CSS playhead and a paused preview holds it with everything else. It draws the first note as its waveform slowed twenty times — --timebase — so how tightly the strokes pack is the pitch; the second as a dashed outline with a tick per cycle; and the room as grain that runs past the edge, because the room rings past the note. The CSS envelope underneath is the same drawing and is what shows with no WebGL; the canvas fades in only once it has drawn.

  5. Silence is the default, and the thumbnail is built for it.

    The one place this study can never play is the landing page: a card’s iframe has pointer events off, so there is no gesture to arm audio with, and a rail that made a noise on approach would be indefensible even where there is one. So the card’s active state is --live — the set striking its own four keys in silence, one gesture rung out at a time, on the screen’s own window, so --hushed plays itself through at its own slower tempo. The tempo is a keyframe on the rack and each beat is an ordinary strike, the one a click makes, so the thumbnail cannot show something the component does not do. A keyframe rather than a timer, because a paused animation fires no iterations: the index’s one animation-play-state rule stops the clock while the rail is being dragged and holds a strike in flight where it stands. The shader’s frame loop takes the pause as --run, handed in on the component’s own element, and its buffer takes the rail’s scale as --buffer-scale.

    Without component.js the component is still the component: the sheet, the key steps, the contours and the envelope are all CSS or markup reading the same tokens, so the card states its set correctly with no script at all. What the script adds is the sound, which CSS cannot reach, the strike, which is an event rather than a state, and the shader — so the arm switch ships disabled in component.html and the script enables it, rather than offering a control that does nothing.

Inspiration
Moog and Korg synthesisers, given a green skin — the deep walnut cheeks, and a panel lettered in modules with a screen let into it. The tuning is borrowed from music rather than from an interface: a fifth and a fourth against a 528Hz tonic, with the overtones at whole-number ratios above it.

notes.md — the full record, including what was tried and dropped