Skip to content
Jace edited this page Aug 30, 2026 · 2 revisions

Timing

Scritto's motion is one spring, one duration per glyph, and a wave of delays across the glyphs that changed. A roll longer than the gap between updates leaves ghosts, the copies of glyphs that are leaving, stacked on screen.

Default roll: 550ms on a spring

550ms, on a spring written as a CSS linear() easing:

linear(0,.1052,.3155,.532,.7112,.8414,.9265,.9765,1.0023,1.013,1.0151,
       1.0133,1.01,1.0068,1.0041,1.0022,1.001,1)

The curve overshoots its target by 1.5%. It crosses the target 46.5% of the way through and settles back, so a glyph arrives, goes slightly too far, and returns. Without the overshoot the roll reads as a fade.

bounce: true swaps in the same shape with more of it: a 4.5% overshoot, same duration.

Duration: one glyph, not the whole change

transition.duration is one glyph's animation, not the whole change. The last glyph starts late, so the transition as a whole runs longer:

change duration last glyph starts whole change
910 400ms 60ms 460ms
99100 400ms 80ms 480ms
DraftPublished 400ms 104ms 504ms
11,000,000 400ms 102ms 502ms

For the true end, whether you are chaining something after it or holding a frame, read it off the animations rather than adding numbers. The glyphs are in the shadow tree and the box is not, so both sets count: the largest endTime across el.shadowRoot.getAnimations() and el.getAnimations() is the answer. el.getAnimations({ subtree: true }) does not cross the shadow boundary and returns only the box's width and text-indent.

Stagger: why glyphs start at different times

stagger is 0.3: the wave of delays spans at most 30% of the duration. The delay a glyph gets comes from where it sits along the changed stretch, not from its index, so a glyph leaving and the glyph replacing it move together.

Normalising over the changed stretch rather than the whole value keeps a small edit lively inside a long value. 2 minutes 12 seconds2 minutes 13 seconds changes one glyph, and that glyph uses the whole duration rather than a thirtieth of it.

Box: why the width uses its own easing

The width and the indent that goes with it use cubic-bezier(0.22, 1, 0.36, 1), deliberately not the roll's spring. Everything displaced by the edge of the host, the element the value is rendered in, is on that curve: the words after it, the row it sits in, and the ghosts riding the same shift. A spring here would overshoot the layout, and a kept run would outrun the shrink and have to come back.

Interrupting a width transition

A width transition already heading for a target is left alone rather than restarted. Every update cancels the animations a commit owns; the width transition is held apart, and _clearWidth keeps its hands off the box while one is in flight. Restarting it looks like cleanup and is a bug, and a faster-than-the-roll cadence produces it on its own. Re-armed mid-flight from whatever width it has reached, with its indent compensation recomputed against a box that is itself moving, it leaves the box blockified and start-aligned, so the value sits left of the centre it's supposed to hold and ghost ink spreads into the slack. Left alone, a real width change animates in full, with its indent travel and box slack intact at any cadence.

A pinned box can be neither measured nor allowed to shrink. A value that wants a different width while a transition is in flight can't be read off the box, pinned somewhere on its way to the old one, because reading it would return the start width and the transition would finish to the old width and snap. The content's own width is summed from its sections instead, and if it differs from the box's the box is landed first and the new transition starts from the width it reached. For the length of a transition the host also refuses to flex-shrink: a container the content overflows would otherwise take back the mask's slack on the way up and pin the box to its own edge on the way down, while the indent decays under it.

Rapid changes

A roll is left running when the next update lands. This is deliberate: a digit halfway out of a counter ticking faster than its own duration finishes leaving rather than disappearing, and the churn harness asserts it.

The cost is that ghosts stack. A value changing faster than its own duration leaves several ghosts over each other, each legible on its own and unreadable together.

Correct that with the duration, not a shortcut. Keep a roll no longer than a few times the gap between the updates driving it and nothing piles up: the site's own readout rolls every step of a drag with a 300ms roll and never gets past one glyph in a slot. A glyph's last stagger delay plus its duration is how long it lives, and dividing that by the update gap is how many are on screen at once.

There is no option to clear a pile by speeding ghost ink up, and a roll sized to its cadence needs none. Running ghosts independently of the roll introduces its own faults: glyphs leaving before they were ever visible, and a rapidly updating readout snapping instead of rolling.

A glyph that survives an update keeps its own roll. Committing a new value only cancels the animations on the glyphs that changed, so a leading digit's entrance runs its full duration even while a trailing digit ticks beside it.

Reduced motion

respectMotionPreference is on by default. With prefers-reduced-motion: reduce the element renders the new value without animating. An element that is offscreen also renders instantly rather than animating out of view.

Clone this wiki locally