Anatomy of the Split-Flap Display
How a Solari departure board is built from four stacked leaves, a two-phase rotateX flip, and a per-column stagger, all on the compositor and revealing on scroll.
↓Scroll to step through it
Four leaves per flap
Every flap is four leaves sharing one hinge line down the middle. Two are static background halves: the new glyph's top (waiting to be revealed) and the old glyph's bottom (still showing). The other two move: the old top hinges away, the new bottom hinges in.
A single clip makes one glyph read as two halves. Each Half
renders the glyph at full card height inside a half-height overflow-clip, one
anchored to the top and one to the bottom, so the two leaves reconstruct one
letter with no visible seam:
<div className="absolute top-0 h-1/2 overflow-hidden">
{/* inner is 200% of the half = full card height, glyph centered */}
<div className="absolute top-0 flex h-[200%] items-center justify-center">
{char}
</div>
</div>Because the same Half is used for the static leaves and (with animation
classes) the moving folds, every leaf always occupies exactly half the digit it
sits in, and backface-visibility: hidden keeps a leaf from showing its reverse
as it passes edge-on.
Fall, then rise
A single flip is two hinge moves that hand off at the seam. The fall leaf
carries the old top and rotates 0 → −90° around origin-bottom, dropping out of
sight. The rise leaf carries the new bottom and rotates 90 → 0° around
origin-top, delayed by exactly one tick so it starts as the fall lands:
style={{ animationDelay: `${fallDelay + TICK}s` }}The rise keyframe uses fill mode both, so during that delay it holds its
start state (rotateX(90deg), edge-on and invisible), and the old bottom stays
visible underneath until the moment the new bottom sweeps over it. Two eased
rotateX curves of 0.12s each make the swap without a gap.
To spin through the alphabet, the digit steps one glyph at a time. Each landed flip
commits settled = next and re-renders; while settled !== target it flips
again, one glyph forward along the charset, keyed on the step so the keyframes
remount and restart cleanly:
const next = flipping ? stepToward(settled, target, charset) : settled;
// …on the rise leaf:
onAnimationEnd={() => { steppedRef.current += 1; setSettled(next); }}The column wave
Snapping every column at once looks digital; a real Solari resolves left to
right. Each column gets startDelay = index × stagger and applies it only to its
first flip of a new target, so the board opens on a wave and then each column
spins freely to its letter:
const firstOfRun = steppedRef.current === 0;
const fallDelay = firstOfRun ? startDelay : 0;The board also starts blank. A useInView(ref, { once: true }) gates the
whole display: until it scrolls into view every column holds a space, then they
all retarget to the value and flip up from nothing. That gives the reveal
the first time the board enters the viewport.
maxFlaps caps the spin. If a column's forward distance along the charset is
longer than the cap, it fast-forwards the head of the run so no single flap ever
spins more than maxFlaps ticks:
if (fwdDistance(settled, desired, charset) > maxFlaps) {
setSettled(charAtOffset(desired, -maxFlaps, charset));
}The result
Four leaves, two eased rotateX curves that hand off at the seam, and a
per-column delay make the board. It uses transforms only, with no
canvas, and resolves on a wave whenever the value changes.
- New top
- static, revealed as the old top peels away
- Old bottom
- static, holds until the new bottom lands
- Fall leaf
- old top · rotateX 0 → −90°
- Rise leaf
- new bottom · rotateX 90 → 0°
{/* two static background halves */}<Half char={next} part="top" /> {/* new top, revealed as the old top falls */}<Half char={settled} part="bottom" /> {/* old bottom, holds until the new rises */}{/* two moving leaves, hinged at the seam */}<Half char={settled} part="top"className="origin-bottom animate-split-flap-fall [backface-visibility:hidden]" /><Half char={next} part="bottom"className="origin-top animate-split-flap-rise [backface-visibility:hidden]" />Anatomy of the Split-Flap Display
How a Solari departure board is built from four stacked leaves, a two-phase rotateX flip, and a per-column stagger, all on the compositor and revealing on scroll.
- New top
- static, revealed as the old top peels away
- Old bottom
- static, holds until the new bottom lands
- Fall leaf
- old top · rotateX 0 → −90°
- Rise leaf
- new bottom · rotateX 90 → 0°
{/* two static background halves */}<Half char={next} part="top" /> {/* new top, revealed as the old top falls */}<Half char={settled} part="bottom" /> {/* old bottom, holds until the new rises */}{/* two moving leaves, hinged at the seam */}<Half char={settled} part="top"className="origin-bottom animate-split-flap-fall [backface-visibility:hidden]" /><Half char={next} part="bottom"className="origin-top animate-split-flap-rise [backface-visibility:hidden]" />Four leaves per flap
Every flap is four leaves sharing one hinge line down the middle. Two are static background halves: the new glyph's top (waiting to be revealed) and the old glyph's bottom (still showing). The other two move: the old top hinges away, the new bottom hinges in.
A single clip makes one glyph read as two halves. Each Half
renders the glyph at full card height inside a half-height overflow-clip, one
anchored to the top and one to the bottom, so the two leaves reconstruct one
letter with no visible seam:
<div className="absolute top-0 h-1/2 overflow-hidden">
{/* inner is 200% of the half = full card height, glyph centered */}
<div className="absolute top-0 flex h-[200%] items-center justify-center">
{char}
</div>
</div>Because the same Half is used for the static leaves and (with animation
classes) the moving folds, every leaf always occupies exactly half the digit it
sits in, and backface-visibility: hidden keeps a leaf from showing its reverse
as it passes edge-on.
- Fall
- old top hinges down · origin-bottom · 0.12s
- Rise
- new bottom hinges up · origin-top · 0.12s
@keyframes split-flap-fall {from { transform: rotateX(0deg); }to { transform: rotateX(-90deg); }}@keyframes split-flap-rise {from { transform: rotateX(90deg); }to { transform: rotateX(0deg); }}--animate-split-flap-fall: split-flap-fall 0.12s cubic-bezier(0.3,0.7,0.4,1) forwards;--animate-split-flap-rise: split-flap-rise 0.12s cubic-bezier(0.22,1,0.36,1) both;Fall, then rise
A single flip is two hinge moves that hand off at the seam. The fall leaf
carries the old top and rotates 0 → −90° around origin-bottom, dropping out of
sight. The rise leaf carries the new bottom and rotates 90 → 0° around
origin-top, delayed by exactly one tick so it starts as the fall lands:
style={{ animationDelay: `${fallDelay + TICK}s` }}The rise keyframe uses fill mode both, so during that delay it holds its
start state (rotateX(90deg), edge-on and invisible), and the old bottom stays
visible underneath until the moment the new bottom sweeps over it. Two eased
rotateX curves of 0.12s each make the swap without a gap.
To spin through the alphabet, the digit steps one glyph at a time. Each landed flip
commits settled = next and re-renders; while settled !== target it flips
again, one glyph forward along the charset, keyed on the step so the keyframes
remount and restart cleanly:
const next = flipping ? stepToward(settled, target, charset) : settled;
// …on the rise leaf:
onAnimationEnd={() => { steppedRef.current += 1; setSettled(next); }}- Stagger
- column n starts n × 0.06s later
- Settled flap
- the value revealed beneath the leaf
<FlapDigittarget={char}active={inView}startDelay={i * stagger} // column i starts i × stagger later/>The column wave
Snapping every column at once looks digital; a real Solari resolves left to
right. Each column gets startDelay = index × stagger and applies it only to its
first flip of a new target, so the board opens on a wave and then each column
spins freely to its letter:
const firstOfRun = steppedRef.current === 0;
const fallDelay = firstOfRun ? startDelay : 0;The board also starts blank. A useInView(ref, { once: true }) gates the
whole display: until it scrolls into view every column holds a space, then they
all retarget to the value and flip up from nothing. That gives the reveal
the first time the board enters the viewport.
maxFlaps caps the spin. If a column's forward distance along the charset is
longer than the cap, it fast-forwards the head of the run so no single flap ever
spins more than maxFlaps ticks:
if (fwdDistance(settled, desired, charset) > maxFlaps) {
setSettled(charAtOffset(desired, -maxFlaps, charset));
}The result
Four leaves, two eased rotateX curves that hand off at the seam, and a
per-column delay make the board. It uses transforms only, with no
canvas, and resolves on a wave whenever the value changes.
Accessibility
const reduce = useReducedMotion();
// …
if (reduce) { setSettled(desired); return; } // land, don't flip
// root:
<div role="img" aria-label={value}>Under prefers-reduced-motion the flip is skipped entirely. Each column sets
straight to its final glyph, with no rotateX at all. The individual flaps are
aria-hidden (they're decorative mechanics), and the board as a whole carries
role="img" with an aria-label of the current value, so a screen reader gets
the word instead of a stream of single characters.
Motion Score
transform: rotateXThe flip leaves hinge on the X axis: the top falls, the bottom rises