-
Notifications
You must be signed in to change notification settings - Fork 1
How the Roll Works
When a value changes, the glyphs that survive slide to their new places, and the rest roll out while their replacements roll in. The rest of this page is how those glyphs are chosen and moved. Nothing here reads meaning: identity is positional, floating runs are capped in travel, and a roll in flight is never cancelled.
The value is split into graphemes before anything else looks at it. A string of plain ASCII takes a fast path that walks the code units directly, because that is most values and Intl.Segmenter is not free. The moment a byte above 0x7F appears, or a CRLF pair, the whole string goes through the segmenter instead.
A glyph made of several code points is never split. 👋 is two code units and one grapheme. A flag is four. é typed as e + a combining accent is two code points that must stay together, or the accent rolls away on its own.
Every ordinary space becomes a non-breaking space at this point. The host, the element the value is rendered in, holds its value on one line, and a value like 1 minute 0 seconds would otherwise be free to collapse or break at the spaces while it is mid-roll.
A value that has spaces still wraps, by word. Glyphs are grouped into words, each word carrying the spaces that follow it, and the host lets a line break between two of those groups and nowhere else. Nothing breaks inside a word, and a value with no spaces in it never breaks at all: its sections would be the only break opportunities, and a number split between two of its own sections is worse than one that overhangs.
Three passes, in order, stopping as soon as the answer cannot improve.
The common prefix. Walk from the start while the old glyph and the new glyph match. Track 1 of 12 to Track 2 of 12 keeps Track before anything else runs.
The longest run either value ends with. Walk back from both ends while they match. That is what makes unread survive 1 unread to 2 unread, and what lets $4.00 survive losing its minus sign.
A run flush with neither end. SwiftUI does not do this. xxlightxx to yylightyy keeps light standing perfectly still, where a prefix-and-suffix-only rule dissolves the whole line. The search tries five alignments: the one layout implies, then two either side of it, nearer side first so ties travel the shortest distance.
A floating run has to be at least two glyphs long. One letter shared by two unrelated words is a coincidence, not a run. Lower it to one and seven to nine flies its n across the value while everything else rolls.
An end-anchored run never chooses to move. Layout moves it, and the distance is whatever the change happens to require. supercalifragilisticlight to light slides the kept light 422 pixels, and it reads as the word staying put while everything in front of it is taken away.
A floating run is different, because keeping it is a choice. Nothing forces light in the middle of one value to be the same light in the middle of another, and allowed to travel far enough, the word appears to swim upstream through text dissolving around it.
So a floating run pays for its travel: at most its own length plus two glyph slots. The two slots cover one case, a group separator appearing. 11 to 1,001 keeps both 1s and pays 74 pixels for the comma that pushed them apart, which is right. A three-letter word cannot cross a twenty-glyph value, which is also right.
Travel is measured in glyph slots and corrected by where the box is anchored, because a run's distance on screen depends on which edge holds still while the box resizes. A value that grows from its right edge moves its content differently from one that grows from its centre.
Every host that changed in the same tick is handled together, in a microtask, in three phases. All of them read, then all of them write, then all of them read again. Interleave them and each host's measurements depend on how many hosts came before it.
- Prepare. Measure where everything currently is, and build the plan: which glyphs are kept, which are entering, which are leaving, and the box each one sits in right now.
- Commit. Rearrange the DOM. Entering glyphs are inserted at zero opacity; each leaving glyph is moved into a separate layer as a ghost, the copy of a glyph that is leaving; kept glyphs stay put in the tree even though their position on screen has just changed.
- Finish. Measure again, then start every animation from the difference between the two reads.
A kept glyph is in a new place because the glyphs around it changed. It is moved back with a transform and then animated to nothing, so it appears to slide from the old position to the new one.
Measure against a kept glyph, not the section containing it. offsetLeft rounds to whole pixels, so measuring the container invents a sub-pixel jump, which shows up as the whole run twitching at the start of every transition.
Glyphs do not all start at once. Each one is delayed by where it sits along the row, so the change reads as a wave crossing the value rather than a flash.
Delay comes from position, not from index. Two glyphs standing on top of each other, the old one leaving and the new one arriving, get the same delay and cross over together, which is the whole point. Indices would put them on different schedules.
The wave is normalised over the changed stretch alone, not the whole value. A one-digit change inside a long number would otherwise start late and finish in a sliver of the duration it was given.
Draft to Published, at a 400ms duration:
| glyph | x | delay |
|---|---|---|
P |
46 | 0ms |
u |
76 | 16ms |
b |
105 | 32ms |
l |
131 | 47ms |
i |
143 | 54ms |
s |
158 | 60ms |
h |
184 | 74ms |
e |
213 | 89ms |
d |
241 | 104ms |
The ghosts of Draft get the same treatment against their own positions: D at 0ms, r at 18ms, a at 28ms, f at 43ms, t at 52ms.
Entering and leaving are the same three properties, mirrored:
opacity 0 → 1
transform translateY(±0.35em) scale(0.6) rotateZ(2deg) → none
filter blur(0.1em) → none
The sign on translateY is the trend: a value that went up has its new glyphs arrive from below and its old ones leave upward. The rotation is two degrees: not enough to read as rotation, and enough to stop the glyph looking like a rigid object being scaled.
The host's width is animated as well, so the words after it slide instead of jumping. An inline host becomes inline-block for the duration, with its vertical padding and border cancelled by matching negative margins so the line it sits on keeps its height.
Whichever edge the box is anchored to, the content is indented back by however far the start edge has to travel and eased to nothing along with the width. That way the row holds where it will land instead of drifting into place.
The width easing is cubic-bezier(0.22, 1, 0.36, 1), not the roll's own spring. A spring overshoots, and a kept run riding an overshooting box outruns the shrink and has to come back.
When the box shrinks, the old row reaches past where the box is heading, and whatever sits after the host slides in over that ink. A gradient mask dissolves the moving edge so the two never overlap.
Each edge earns a band by travelling, since a travelling edge is what leaves ink behind. The band is worn by the layer holding the ghosts, never by the host, so the live value is never dimmed. No ink wears two bands either, which would square the ramp into the hard edge the mask exists to avoid.
The band sits slightly past the content rather than inside it. The box converges on its final width asymptotically, so the last glyph spends the back half of the transition a pixel or two beyond the edge at full opacity. A band inside the box would dim it the whole way and then pop when the mask lifts.
A mask paints no further than its element's border box, so grow that box past the ink on every side that is not being faded. Edges is the whole subject: where the room comes from, the rules that keep a fade from becoming a cut, and each measurement.
Ghosts are parked in their own layer, positioned by a transform from the host's start edge rather than by layout, so the box can resize underneath them without dragging them along.
A value that wrapped leaves ink on every line it stood on, one parked group per line, each dissolved by its own swept band. One group would be a single row, and the moment the value committed the second line's ink would be pulled up onto the first, which reads as the old value jumping rather than dissolving. Each group is placed against the first fragment of its section, not the union of them: a wrapped section's bounding box spans the whole column while offsetLeft is measured from where its first line starts, and pairing the two puts old ink a line's indent off.
They are deliberately not cancelled when the next update lands. A digit halfway out of a rapidly-changing counter finishes leaving rather than popping. The cost is that a value spammed faster than its own duration stacks several ghosts over each other. Keep a roll no longer than the gap between updates rather than speeding the pile away. See Timing.
Glyph elements are recycled through a pool rather than created and thrown away. A release rides the animation that finishes it, spaces included: a space has no ink to roll, so it runs an animation that does nothing rather than a wall-clock timer. Swap it for a timer and anything holding the roll still, a paused scrub or a background tab, loses its spaces while the glyphs around them stay.