Stage
The composition tier — several objects, one camera, and named beats to cut between.
An assembly is one object. A stage is a scene: several objects, one camera, a lighting rig, and an ordered set of beats to move between. It is the layer the marketing site and the app can share, so the same illustration tells the same story in both places rather than being rebuilt twice and drifting.
Beats are states, not transitions
A beat names where the camera is, how far things are apart, and what is being looked at. It does not describe how you got there. That is what lets the same beat read identically whether you cut to it, ease into it, or land on it from scroll.
beats: [
{ id: 'whole', explode: 0, camera: { zoom: 1 } },
{ id: 'accounts', emphasise: ['ground'], camera: { zoom: 1.7, target: { x: 40 } } },
{ id: 'apart', explode: 1, camera: { zoom: 0.9 } },
]beat is a number, and fractions are legal. 1.5 is halfway from beat 1 to beat 2, so a
scroll-driven marketing page and a click-through inside the app drive one stage through one
code path instead of two.
emphasise names what is at full strength; everything unnamed recedes rather than
disappearing, so the context stays on screen. A beat that names no emphasis holds everything
at full, which means quiet beats need no bookkeeping.
One line of copy, per beat
Relief hangs no labels on its geometry. There are no per-island names, no per-card percentages, nothing tethered to a point in the world. A drawing covered in labels stops being a drawing, and the geometry is already saying the quantitative part.
What a stage has instead is one line of subtext, belonging to the beat rather than to any object in the scene:
{ id: 'accounts', subtext: 'every account an island, sized by what it carries' }It is the whole of what the illustration says in words, so write it as a sentence with some
personality rather than a caption fragment. It also becomes the beat's accessible
description, which means the line a sighted reader sees and the line a screen reader hears
are the same sentence rather than two that drift apart. The visible copy is aria-hidden
precisely because it is already spoken once through the frame.
Zoom frames, dolly does not
The camera is orthographic. Moving it back does not make anything smaller, so dolly is not
a framing control — it moves where fog is measured from and where the clip planes fall, and
nothing else. zoom and margin do the framing.
This is worth stating plainly because every instinct from perspective work says otherwise, and a dolly slider that appears to do nothing reads as a bug rather than as geometry.
target pans in view space, x right and y up on screen, because that is the frame you are
actually nudging rather than a point in the tipped world.
Tuning, then keeping the numbers
Turn on Override camera and the tuned values pin every beat, so you can dial one look and then
step through the story checking it still holds. Copy camera spec puts the result on the
clipboard as a CameraSpec you paste straight back into the stage:
{
"zoom": 1.7,
"margin": 1.4,
"target": { "x": 40, "y": -20 },
"angle": { "x": 54.736, "z": 45 }
}That round trip is the point of the panel. Tuning that cannot leave the browser is a toy.
Motion
Transitions ease on a cubic in-out, symmetric with zero slope at both ends, so a cut settles
rather than arriving still moving. Under prefers-reduced-motion the stage cuts instead,
which is the same story without the travel — no beat is lost.
Axes are the instrument register
Tick marks along the world axes, with every fifth mark major, the way a real rule is read. They give the isometric a measured frame without a ground grid closing the space in.
Cells and the grid
A stage can carry a grid: a lattice of cells on the ground with levels climbing from it.
Counts are cells on each side of the origin, so the origin cell is always the centre, and
rise is the world height of one level (it defaults to the cell size). Stage space has z
as up, so a level is a cell's z.
grid: { cell: 120, columns: 6, rows: 3, levels: 5, rise: 60 },
objects: [{ id: 'sky', assembly: atmosphericSky(alerts), cell: { z: 5 } }],An object's cell is its home, and at is still honoured as a world offset on top of it.
With a grid present the camera frames the lattice rather than the visible objects, so the
frame holds still while things move. Stages without a grid keep the old rule.
Poses
A beat can pose every object on its own: poses maps an object id to a cell, an offset, a
spin about z, a scale, an opacity and an explode. Each field overrides the object's home
field by field, so a pose that only names a level keeps the object's column and row.
{ id: 'weather', poses: { sky: { cell: { z: 3 }, explode: 0.2 }, ground: { opacity: 0.5 } } }Explode resolves pose, then object, then beat, then zero. Everything in a pose tweens.
Transitions and presets
A beat's transition says how it is entered from the one before: duration, an ease
(linear, in, out or inOut), a preset, and an optional stagger in milliseconds
between successive changing objects. Presets are plain data on stage.presets, keyed by
id, so a stage JSON carries everything needed to play it back:
presets: {
settle: { id: 'settle', label: 'Settle', ease: 'out', enter: { cell: { z: -2 }, opacity: 0 } },
}enter is where an object starts when the previous beat did not show it, relative to its
resolved pose; exit is where it ends when the next beat does not; through is a bump at
the middle of a move; overshoot lets the spatial channels travel past the target. Cells
and offsets add, spin adds, scale and opacity multiply, and explode is absolute. With no
preset, objects fade and moves ease. Camera and emphasis never overshoot.
An object shown only in the next beat is not drawn while resting on this one. It appears the moment the transition starts.
Ghosts, highlight and picking
ghosts names beat indices to draw as onion skins under the live frame, at a fraction of
their opacity and sharing the live geometry. highlight names object ids whose line work
takes the accent role. onPick is called with the object under a click, or null for empty
stage; drags do not pick.
onDrag reports a pointer drag over an object as start, move and then end or cancel
events, each carrying the object id, the origin where the drag began and the current
point. Both are stage-space points on the plane at the level the object was on when the
drag started, so a consumer can move it across the ground without solving the camera. A
drag never fires onPick, and a plain click never fires onDrag.
Driving it from outside
Pass a ref and the stage hands back a handle: seek(position) applies a frame without
going through React, renderNow() paints synchronously, and capture({ position, width })
returns a PNG data URL of a position with ghosts and highlight hidden, restoring the live
frame after. A transport drives seek every frame; a thumbnail strip drives capture.
Geometry is built once per structural change and mutated per frame: position, spin, scale, opacity and explode all apply in place. A tween no longer re-extrudes anything, and a pose edit never rebuilds a contour.
Look lanes
The stage draws in three ways, and they share one camera and one cell grid.
| Path | When | Geometry |
|---|---|---|
| Relief | The default. look omitted or "relief". | Extrude blocks as outline, solid, or wire. |
| Lit iso | assembly.look === "lit-iso". | Shade volumes (box, cylinder, cone, sphere, extrude) with a soft key. Outline plates stay unlit, so the extra lights do not wash them. |
| Mesh | assembly.mesh is set. Wins over look. | Characters: glTF or a block stand-in, plus an AnimationMixer. |
Soft-3D and flat are the next look-lane siblings; they are not on the stage yet. Lit-iso albedo follows surface roles mixed toward ink, so a theme switch still recolors the object and a white paper fill can still take shade. Roughness, glow intensity and the key direction do not come from CSS — they are authored on the volume.
Accessibility
- The host is
aria-hiddenunless it has a label or the current beat has subtext, which promotes it torole="img"with that line as its description. - Subtext is spoken once, through the host. The visible caption is hidden from the tree so it is not read twice.
- Under
prefers-reduced-motionthe stage cuts between beats instead of easing. - Nothing in the canvas is focusable. Picking and dragging are pointer-only, so anything that depends on them needs another way in, which is what the beat machine's rail, arrow keys and inspector are for.
Install
shadcn add @gesso/relief-stage