GodUIGodUI
129Follow on X

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.

01/04Four leaves per flap
B
A
A
B
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°
tsx
{/* 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]" />
01

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:

tsx
 
<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.

02/04Fall, then rise
Fall
old top hinges down · origin-bottom · 0.12s
Rise
new bottom hinges up · origin-top · 0.12s
tsx
@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;
02

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:

tsx
 
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:

tsx
 
const next = flipping ? stepToward(settled, target, charset) : settled;
// …on the rise leaf:
onAnimationEnd={() => { steppedRef.current += 1; setSettled(next); }}
03/04The column wave
Stagger
column n starts n × 0.06s later
Settled flap
the value revealed beneath the leaf
tsx
<FlapDigittarget={char}active={inView}startDelay={i * stagger}  // column i starts i × stagger later/>
03

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:

tsx
 
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:

tsx
 
if (fwdDistance(settled, desired, charset) > maxFlaps) {
  setSettled(charAtOffset(desired, -maxFlaps, charset));
}
04/04Result
04

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

tsx
 
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

Split-Flap DisplaySS: Compositor-only
Stransform: rotateXThe flip leaves hinge on the X axis: the top falls, the bottom rises
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →