Catalog / Case-study device
Stepped figure
A figure the page serves as a complete still, with its controls hidden; mounted, the reader steps, plays, scrubs and resets it through the frames of a trace in the figure's own markup.
- Family
- running figure
- Status
- lab
- Used for
- Figure
- Size
- 2,373 of 2,800 bytes, gzipped
mount(element)A case study claims that something happens over time: two parties agree tick by tick and part at one, a request is turned back at a boundary. Prose asserts it; a static diagram shows structure, not order. A running figure carries out the thing the paragraph above it claims, and the reader makes it go, one step at a time. This recipe is the player. The figure, its words and its trace are the page’s.
Use
import { mount } from './stepped-figure';
mount(document.querySelector('[data-figure]'), { interval: 700 });
The figure is served complete, at its most informative frame, with the controls and the marker hidden and every step sentence visible:
<figure data-figure>
<div data-stage role="group" aria-labelledby="fig-1-caption">
<div data-part="c4" data-state="confirmed"><b data-text="c4">9b2e</b></div>
<span data-marker hidden></span>
</div>
<div data-controls hidden>
<button data-do="back" aria-label="Back">...</button>
<button data-do="play">
<span data-when="idle">Play</span>
<span data-when="playing" hidden>Pause</span>
<span data-when="ended" hidden>Replay</span>
</button>
<button data-do="next" aria-label="Next">...</button>
<input type="range" data-do="seek" aria-label="Step">
<output data-count>8 / 11</output>
<button data-do="reset">Reset</button>
</div>
<ol data-steps><li>...</li></ol>
<figcaption id="fig-1-caption">...</figcaption>
<script type="application/json" data-frames>{ "rest": 7, "frames": [ ... ] }</script>
</figure>
A frame says which part is in which state, which text slot shows which
string, and which part the marker sits on. It never says where
anything is, and it never holds a word: { "s": { "c4": "predicted" }, "t": { "c4": "9b2e" }, "m": "c4" }. Frames are whole, not deltas, so
the scrubber can jump anywhere. rest is the index of the still, and
it is authored, never computed.
Every word is the page’s, in the page’s language: the three labels of the play button, the step sentences, the caption that states the conclusion, and the line that says whether the figure is an extract of the project’s code, a recording of it, a model, or an illustration. The recipe contains no strings at all.
What the still is
The frame the page is served with is the most informative frame of the run, not the first: the tick where the hashes part, with the agreeing ticks before it and the empty ticks after. With no script, in print, in a link preview and under reduced motion, that still is the whole figure. The player is an enhancement in the plain sense: taking it away removes convenience and no information.
How it moves
Nothing moves until the reader starts it. Play is a chain of
setTimeout, one per step, never a frame loop: a stepped figure
changes about once every 700 ms, and a frame loop would wake sixty
times a second to do nothing. The only tweened thing is the marker,
which travels with one Element.animate on transform and is
cancelled before the next travel begins. A run ends on its last frame
and stays there, with play reading its third label.
Pressing play dispatches an event on document; every other mounted
player pauses when it hears one that is not its own. An event rather
than a module variable, because a recipe is copied and two copies of
one file share no state. The figure also pauses when the tab is hidden
or when it leaves the screen, and it never resumes by itself.
The step list is a polite live region while the reader steps by hand,
and silent while playing. The scrubber’s aria-valuetext is the
current sentence. Focus is never moved. The controls are native
buttons and a native range, so Tab, Space, Enter, the arrows, Home and
End come for nothing, and the recipe sets no key handler anywhere.
Options
| Option | Default | What it does |
|---|---|---|
interval |
700 | Ms between steps. A page may set --figure-interval on the figure; the dial wins when it is not the default |
travel |
220 | Ms the marker takes between parts, held under half the interval; 0 jumps |
playFrom |
0 | 0 from the first frame, 1 from the current one |
reduced |
0 | Under reduced motion: 0 hides play and keeps step and scrub; 1 keeps play as hard cuts |
steps |
0 | 0 shows the current sentence alone; 1 shows all, the current one marked |
pauseOffscreen |
true | Pause off screen and in a hidden tab |
render |
null | Called after each frame is applied, for a figure that draws onto a canvas inside it. Not a dial |
No speed control, no loop, nothing bound to scroll: a figure that loops is an ambient animation with a caption, and stepping driven by scroll is scroll takeover in a reading page.
Versions
- Figure: the defaults.
- Brisk: interval 450, travel 120.
- Cuts: play stays under reduced motion, with no tweening.
- Annotated: every step sentence visible, the current one marked.
Where it must not go
Above the claim it supports; beside the reading column while it is being read; in a listing, a card, a hero or a page header; on scroll; in place of a number. Under the claim, full width, between two runs of prose, and one figure running at a time.
What it does when things are missing
The still, as served, with every sentence visible and no controls:
before the player mounts, with no script, after destroy(), in print,
and when data-frames is missing or unparsable, where mount returns
a no-op handle. A second mount on a figure that already has a player
is a no-op too. Reduced motion, decided through matchMedia once and
re-read when it changes, is the still with steps on demand and no
tween. No IntersectionObserver: it runs and pauses only on a hidden
tab. No ResizeObserver: the marker is placed on each step and may
sit wrong after a resize until the next one. No Element.animate, or
one that throws: the marker jumps. At rest nothing is scheduled.
Learned while building it
- Restore attributes, not properties.
destroy()must return the served markup byte for byte. Setting a range’sminto''adds amin=""the page never had; reading each attribute at mount and putting it back, or removing it, is the only honest reset. - Chrome keeps an empty
styleafter a CSSOM write. Removing the marker’sstyleattribute straight afterstyle.transformwas set leftstyle=""on it, and the thirty-mounts check saw the markup drift. Setting the attribute to an empty string first, then removing it, leaves nothing. - Measure the marker after showing it. A hidden element has no
size, so an
offsetWidthread beforehiddenis cleared centres the marker half its width off. - The run ends on the last frame, not one interval after it. A tick that shows the last frame and then waits for the next tick to notice there is none leaves the play button reading Pause over a figure that has stopped.
/**
* stepped-figure: a player for a figure the page already serves.
*
* The figure is complete as a still: its parts carry the rest frame's
* states, its sentences are all visible, its controls are hidden. This
* mounts on it, shows the controls, and moves the parts between the
* frames of the trace in the figure's own `[data-frames]`. It holds no
* words, no model and no colour: every label is the page's, every
* state's look is the page's CSS on `data-state`.
*
* Nothing moves until the reader starts it. Playing is a timer, one
* step at a time; stepping and scrubbing are native controls. One
* figure runs at a time, page-wide. Reduced motion is the still, with
* steps on demand. `destroy()` puts the document back as served.
*/
export interface Options {
/** Milliseconds between steps while playing. A page may set --figure-interval on the figure instead; the dial wins when it is not the default. */
interval: number;
/** Milliseconds the marker takes to travel between parts. 0 jumps. Held under half the interval. A page may set --figure-travel instead. */
travel: number;
/** Where a press of play begins: 0 from the first frame, 1 from the current one. */
playFrom: number;
/** Under reduced motion: 0 step and scrub only, with play hidden; 1 play stays, as hard cuts with no tweening. */
reduced: number;
/** The step sentences while mounted: 0 the current one alone, 1 all of them, the current one marked. */
steps: number;
/** Pause when the figure leaves the screen or the tab is hidden. It never resumes by itself. */
pauseOffscreen: boolean;
/** Called after each frame is applied, for a consumer that draws onto a canvas inside the figure. Not a dial. */
render: ((index: number, frame: Frame, element: HTMLElement) => void) | null;
}
export interface Frame {
s: Record<string, string>;
t?: Record<string, string>;
m?: string;
}
export interface Trace {
rest: number;
frames: Frame[];
}
export interface Handle {
update(options: Partial<Options>): void;
destroy(): void;
}
export const DEFAULTS: Options = {
interval: 700,
travel: 220,
playFrom: 0,
reduced: 0,
steps: 0,
pauseOffscreen: true,
render: null,
};
export const PRESETS: Record<string, Partial<Options>> = {
Figure: {},
Brisk: { interval: 450, travel: 120 },
Cuts: { reduced: 1 },
Annotated: { steps: 1 },
};
const NOOP: Handle = { update() {}, destroy() {} };
const PLAY_EVENT = 'stepped-figure:play';
const LABELS = ['idle', 'playing', 'ended'] as const;
export function clampIndex(index: number, length: number): number {
if (!Number.isFinite(index)) return 0;
return Math.min(Math.max(0, Math.trunc(index)), Math.max(0, length - 1));
}
/** The figure's own frames, or null for anything missing or malformed. */
export function readTrace(element: HTMLElement): Trace | null {
try {
const script = element.querySelector('script[data-frames]');
const parsed: unknown = JSON.parse(script?.textContent ?? '');
if (!parsed || typeof parsed !== 'object') return null;
const { rest, frames } = parsed as Partial<Trace>;
if (!Array.isArray(frames) || frames.length === 0) return null;
if (!frames.every((frame) => frame && typeof frame === 'object' && typeof frame.s === 'object')) return null;
return { rest: clampIndex(Number(rest), frames.length), frames };
} catch {
return null;
}
}
export function mount(element: HTMLElement, options: Partial<Options> = {}): Handle {
try {
const trace = readTrace(element);
if (!trace || element.hasAttribute('data-stepped')) return NOOP;
return run(element, trace, { ...DEFAULTS, ...options });
} catch {
return NOOP;
}
}
// An attribute put back as served, or taken off when it was not there.
// Set to '' before it is removed: in Chrome, removing `style` straight
// after a write through `element.style` leaves an empty `style=""`
// behind (seen 2026-10-14), and the served markup had none.
const restore = (target: Element, name: string, value: string | null | undefined): void => {
if (value === null || value === undefined) {
target.setAttribute(name, '');
target.removeAttribute(name);
} else target.setAttribute(name, value);
};
function run(element: HTMLElement, trace: Trace, o: Options): Handle {
const q = <T extends Element>(selector: string): T | null => element.querySelector<T>(selector);
const stage = q<HTMLElement>('[data-stage]');
const marker = q<HTMLElement>('[data-marker]');
const controls = q<HTMLElement>('[data-controls]');
const parts = new Map<string, HTMLElement>();
for (const part of element.querySelectorAll<HTMLElement>('[data-part]')) parts.set(part.dataset.part ?? '', part);
const texts = new Map<string, HTMLElement>();
for (const text of element.querySelectorAll<HTMLElement>('[data-text]')) texts.set(text.dataset.text ?? '', text);
const steps = Array.from(element.querySelectorAll<HTMLElement>('[data-steps] > li'));
const stepList = q<HTMLElement>('[data-steps]');
const play = q<HTMLButtonElement>('[data-do="play"]');
const back = q<HTMLButtonElement>('[data-do="back"]');
const next = q<HTMLButtonElement>('[data-do="next"]');
const seek = q<HTMLInputElement>('[data-do="seek"]');
const reset = q<HTMLButtonElement>('[data-do="reset"]');
const count = q<HTMLElement>('[data-count]');
const labels = (when: string): HTMLElement[] => Array.from(element.querySelectorAll<HTMLElement>(`[data-when="${when}"]`));
// What was served, to put back on destroy. Attributes, not
// properties: a served range with no min has no attribute to put
// back, and setting min to '' would add one.
const served = {
states: new Map([...parts].map(([id, part]) => [id, part.getAttribute('data-state')])),
texts: new Map([...texts].map(([id, text]) => [id, text.textContent ?? ''])),
hiddenSteps: steps.map((step) => step.hasAttribute('hidden')),
count: count?.textContent ?? '',
live: stepList?.getAttribute('aria-live'),
seek: seek ? ['min', 'max', 'step', 'aria-valuetext'].map((name) => [name, seek.getAttribute(name)] as const) : [],
seekValue: seek?.value ?? '',
markerStyle: marker?.getAttribute('style'),
};
const last = trace.frames.length - 1;
let index = trace.rest;
let timer: ReturnType<typeof setTimeout> | null = null;
let ended = false;
let travel: Animation | null = null;
let reduced = false;
const media = typeof matchMedia === 'function' ? matchMedia('(prefers-reduced-motion: reduce)') : null;
// A duration from the dial, or from the page's custom property when
// the dial is at its default, or the default.
const duration = (dial: number, fallback: number, property: string): number => {
if (dial !== fallback) return Math.max(0, dial);
const css = parseFloat(getComputedStyle(element).getPropertyValue(property));
return Number.isFinite(css) && css >= 0 ? css : fallback;
};
const intervalMs = (): number => Math.max(50, duration(o.interval, DEFAULTS.interval, '--figure-interval'));
const travelMs = (): number => duration(o.travel, DEFAULTS.travel, '--figure-travel');
const placeMarker = (tween: boolean): void => {
if (!marker || !stage) return;
const frame = trace.frames[index];
const target = frame.m ? parts.get(frame.m) : undefined;
if (!target) {
marker.hidden = true;
return;
}
const from = marker.style.transform;
const wasHidden = marker.hidden;
// Shown before it is measured: a hidden marker has no size.
marker.hidden = false;
const box = target.getBoundingClientRect();
const home = stage.getBoundingClientRect();
const x = box.left - home.left + box.width / 2 - marker.offsetWidth / 2;
const y = box.top - home.top - marker.offsetHeight / 2;
const to = `translate(${x}px, ${y}px)`;
marker.style.transform = to;
if (travel) travel.cancel();
travel = null;
const ms = travelMs();
if (!tween || wasHidden || !from || reduced || ms <= 0 || typeof marker.animate !== 'function') return;
try {
travel = marker.animate([{ transform: from }, { transform: to }], { duration: Math.min(ms, intervalMs() / 2), easing: 'ease-out' });
} catch {
travel = null;
}
};
const show = (at: number, tween = true): void => {
index = clampIndex(at, trace.frames.length);
const frame = trace.frames[index];
for (const [id, part] of parts) {
const state = frame.s[id];
if (state) part.setAttribute('data-state', state);
else part.removeAttribute('data-state');
}
for (const [id, text] of texts) {
const value = frame.t?.[id];
if (value !== undefined && text.textContent !== value) text.textContent = value;
}
steps.forEach((step, i) => {
step.hidden = o.steps === 0 && i !== index;
if (i === index) step.setAttribute('aria-current', 'step');
else step.removeAttribute('aria-current');
});
const sentence = steps[index]?.textContent ?? '';
if (seek) {
seek.value = String(index);
seek.setAttribute('aria-valuetext', sentence);
}
if (count) count.textContent = `${index + 1} / ${trace.frames.length}`;
placeMarker(tween);
if (o.render) o.render(index, frame, element);
};
const setLabel = (when: (typeof LABELS)[number]): void => {
for (const name of LABELS) for (const span of labels(name)) span.hidden = name !== when;
};
const playing = (): boolean => timer !== null;
const pause = (): void => {
if (timer !== null) clearTimeout(timer);
timer = null;
if (stepList) stepList.setAttribute('aria-live', 'polite');
setLabel(ended ? 'ended' : 'idle');
};
// The run ends the moment the last frame is shown: it stays there,
// and the play button offers a replay.
const tick = (): void => {
timer = null;
show(index + 1);
if (index >= last) {
ended = true;
pause();
return;
}
timer = setTimeout(tick, intervalMs());
};
const start = (): void => {
if (reduced && o.reduced === 0) return;
document.dispatchEvent(new CustomEvent(PLAY_EVENT, { detail: element }));
if (ended || o.playFrom === 0) show(0, false);
ended = false;
if (stepList) stepList.setAttribute('aria-live', 'off');
setLabel('playing');
timer = setTimeout(tick, intervalMs());
};
const stepTo = (at: number): void => {
pause();
ended = false;
show(at);
setLabel('idle');
};
const applyReduced = (): void => {
reduced = media?.matches === true;
if (reduced && playing()) pause();
if (play) play.hidden = reduced && o.reduced === 0;
};
const onPlay = (): void => (playing() ? pause() : start());
const onBack = (): void => stepTo(index - 1);
const onNext = (): void => stepTo(index + 1);
const onSeek = (): void => stepTo(Number(seek?.value));
const onReset = (): void => stepTo(trace.rest);
const onOther = (event: Event): void => {
if ((event as CustomEvent).detail !== element && playing()) pause();
};
const onVisibility = (): void => {
if (document.hidden && o.pauseOffscreen && playing()) pause();
};
const onMedia = (): void => applyReduced();
play?.addEventListener('click', onPlay);
back?.addEventListener('click', onBack);
next?.addEventListener('click', onNext);
seek?.addEventListener('input', onSeek);
reset?.addEventListener('click', onReset);
document.addEventListener(PLAY_EVENT, onOther);
document.addEventListener('visibilitychange', onVisibility);
media?.addEventListener?.('change', onMedia);
let seen: IntersectionObserver | null = null;
let sized: ResizeObserver | null = null;
if (typeof IntersectionObserver === 'function') {
seen = new IntersectionObserver(
(entries) => {
let onScreen = true;
for (const entry of entries) onScreen = entry.isIntersecting;
if (!onScreen && o.pauseOffscreen && playing()) pause();
},
{ threshold: 0 },
);
seen.observe(element);
}
if (typeof ResizeObserver === 'function' && stage) {
sized = new ResizeObserver(() => placeMarker(false));
sized.observe(stage);
}
element.setAttribute('data-stepped', '');
if (seek) {
seek.min = '0';
seek.max = String(last);
seek.step = '1';
}
if (controls) controls.hidden = false;
applyReduced();
show(trace.rest, false);
setLabel('idle');
return {
update(changes) {
Object.assign(o, changes);
applyReduced();
show(index, false);
},
destroy() {
pause();
if (travel) travel.cancel();
travel = null;
seen?.disconnect();
sized?.disconnect();
play?.removeEventListener('click', onPlay);
back?.removeEventListener('click', onBack);
next?.removeEventListener('click', onNext);
seek?.removeEventListener('input', onSeek);
reset?.removeEventListener('click', onReset);
document.removeEventListener(PLAY_EVENT, onOther);
document.removeEventListener('visibilitychange', onVisibility);
media?.removeEventListener?.('change', onMedia);
for (const [id, part] of parts) restore(part, 'data-state', served.states.get(id));
for (const [id, text] of texts) text.textContent = served.texts.get(id) ?? '';
steps.forEach((step, i) => {
step.hidden = served.hiddenSteps[i];
step.removeAttribute('aria-current');
});
if (count) count.textContent = served.count;
if (stepList) restore(stepList, 'aria-live', served.live);
if (seek) {
for (const [name, value] of served.seek) restore(seek, name, value);
seek.value = served.seekValue;
}
setLabel('idle');
if (play) play.hidden = false;
if (marker) {
marker.hidden = true;
restore(marker, 'style', served.markerStyle);
}
if (controls) controls.hidden = true;
element.removeAttribute('data-stepped');
},
};
}