Gesso
Brand

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.

PathWhenGeometry
ReliefThe default. look omitted or "relief".Extrude blocks as outline, solid, or wire.
Lit isoassembly.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.
Meshassembly.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-hidden unless it has a label or the current beat has subtext, which promotes it to role="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-motion the 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

On this page