SplitFlap
Mechanical board character flip animation.
Live demo
01Departures board
Solari charset Β· forward Β· auto-rotates every 4.5 s
Editorial dark surface β Solari boards are dark by design, so this panel stays dark in either page theme.
Live clock
Solari charset Β· ticks every second
Counter β shortest path
Digits only Β· click to bump
Cascade intensity
Same value, three intensities
Implementation
02<script lang="ts">
import SplitFlap from '$lib/components/SplitFlap.svelte';
let value = $state('LONDON');
</script>
β
<SplitFlap {value} charset="alnum" stagger={60} flipDuration={320} size="lg" />Each cell is a 3D card. On a value change the top half rotates down through intermediate charset positions while the bottom catches the new glyph β the same mechanic Solari split-flap boards use. Per-character stagger creates a left-to-right cascade. The component renders the target value statically on first paint and only kicks the cascade off after onMount, so server HTML matches post-cascade β no hydration mismatch.
Logic explainer
03What Does It Do? (Plain English)
SplitFlap is the mechanical Solari board you see in old European train stations and airport arrivals halls β a row of cards each split horizontally in two; when the value changes, the top half drops down through intermediate charset positions while the bottom half catches the next glyph and settles. Per-character stagger creates a left-to-right cascade as the whole word updates.
Asset-free: every flap, hinge, and divider is CSS β transform: rotateX(...) plus two stacked half-glyph layers. Change value and the cells re-tick from their current state to the new target, never spinning more than one full charset traversal.
How It Works (Pseudo-Code)
state:
displayed: string[] // current glyph in each cell
flipping: boolean[] // is each cell mid-rotation right now?
timers: Timeout[]
prefersReduced = matchMedia query
derive:
resolvedCharset = pickCharset(charset) // digits | alpha | alnum | solari
cells = value.toUpperCase().split('')
helpers (pure, exported):
pickCharset(name) β SplitFlapCharset
nextCharIndex(curIdx, tgtIdx, len, direction) β next index, one step toward target
forward: always (cur + 1) mod len
shortest: pick whichever direction is fewer ticks
buildTickSequence(from, to, charset, direction) β string[] of intermediate glyphs ending in `to`
frameDelay(index, baseStagger, intensity) β ms before this cell's first tick
on value change:
for each cell index i:
if displayed[i] === cells[i]: continue // already on target
if prefersReduced: displayed[i] = cells[i]; continue
sequence = buildTickSequence(displayed[i], cells[i], charset, direction)
startDelay = frameDelay(i, stagger, intensity)
for step, char in sequence:
setTimeout(at startDelay + step * flipDuration):
displayed[i] = char
flipping[i] = true
setTimeout(after flipDuration): flipping[i] = false
on unmount:
clearTimeout for every queued timerThe CSS handles the visible rotation: flipping[i] === true adds .sf-flipping, which animates .sf-flap-top from rotateX(0deg) β rotateX(-90deg) and .sf-flap-bottom from rotateX(90deg) β rotateX(0deg). Each tick lasts flipDuration ms, and the bottom half settles holding the new glyph.
The Core Concept: Tick Sequence + Stagger Cascade
A real Solari board doesn't teleport between glyphs β it ticks through every intermediate position. buildTickSequence reproduces that:
from = 'A', to = 'D', charset = ' ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', direction = 'forward'
A β B β C β D
tick tick tick (lands)
returned: ['B', 'C', 'D'] β three flaps, three flipDurations'shortest' direction picks the cheaper path (forward vs backward) for each cell, which feels snappier for clocks/counters; 'forward' always cycles forward through the charset, giving the classic "every change costs a full traversal" Solari rhythm. The function caps at charset.length iterations as a hard guard β a malformed direction can never spin a cell forever.
The cascade comes from frameDelay(index, baseStagger, intensity):
delay_i = i * baseStagger * intensity
baseStagger = 60ms, intensity = 1
cell 0: 0ms starts immediately
cell 1: 60ms starts a beat later
cell 2: 120ms
cell 3: 180msEach cell's flap-tick chain is offset, producing a left-to-right wave even though every cell is animating in parallel.
t=0 t=60 t=120 t=180
βββββ βββββ βββββ βββββ
β A β β B β β A β β A β
βββΌββ βββΌββ βββΌββ βββΌββ
βββββ βββββ βββββ βββββ
β B β β C β β B β β A β
βββββ βββββ βββββ βββββ
βββββ βββββ βββββ βββββ
β C β β D β β C β β B β
βββββ βββββ βββββ βββββ
βcell 0β βcell 1ββ βcell 2ββ βcell 3β
ticking ticking waiting waitingCSS Animation Strategy
Each cell stacks five layers inside a 3D-preserved container:
ββββββββββββββββββββββ β .sf-cell (perspective: 800px on parent)
β ββββββββββββββββ β
β β .sf-half top β β static β shows top half of current glyph
β ββββββββββββββββ€ β β .sf-divider (1px line at 50%)
β β .sf-half bot β β static β shows bottom half of current glyph
β ββββββββββββββββ β
β ββββββββββββββββ β
β β .sf-flap-top β β rotates 0 β -90deg (top falls)
β ββββββββββββββββ€ β
β β .sf-flap-bot β β rotates 90 β 0deg (bottom rises)
β ββββββββββββββββ β
ββββββββββββββββββββββWhen .sf-flipping is added:
.sf-flipping .sf-flap-top { animation: sf-flap-down 320ms ease-in forwards; }
.sf-flipping .sf-flap-bottom { animation: sf-flap-up 320ms ease-out forwards; }
@keyframes sf-flap-down { 0%{rotateX(0)} 100%{rotateX(-90deg)} }
@keyframes sf-flap-up { 0%{rotateX(90)} 100%{rotateX(0)} }backface-visibility: hidden on each flap hides its back during the rotation, so you only ever see the painted face. transform-origin: bottom center for the top flap and top center for the bottom flap means the hinge sits exactly on the divider β the geometry of a real split-flap mechanism.
Reduced-motion users get animation: none; opacity: 0 on both flaps β the cell snaps to the new glyph immediately, no rotation.
Performance
- One
setTimeoutper tick step per cell. A 7-cell update rolling through 4 charset positions is 28 timers β trivial. - All timers are tracked in a single array and cleared on unmount or value change.
buildTickSequencecaps atcharset.length, so even pathological input can't queue an infinite chain.- The animation itself is two
transform: rotateXproperties β pure GPU compositor work, no layout, no paint thrash. aria-busyflips while any cell is mid-flight, so assistive tech can defer announcements until the row settles.
State Flow Diagram
value changes
β
βΌ
syncCells(cells) ββ reduced-motion βββΆ [settled instantly]
β
β schedule per cell:
βΌ
for cell i: setTimeout(startDelay):
βΌ
[flipping] ββ flipDuration tick βββΆ displayed[i] = next char
β flipping[i] = true
β
β next setTimeout(flipDuration): flipping[i] = false
βΌ
loop until last char in sequence
β
βΌ
[settled] target glyph painted, flipping = false
aria-busy = !allSettled (every cell idle β polite live region announces)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
value |
string |
required | Target string. Uppercased before display. |
charset |
'digits' | 'alpha' | 'alnum' | 'solari' |
'alnum' |
Allowed glyph set; cells tick through this in order. |
stagger |
number |
60 |
Milliseconds between adjacent cells' first flips. |
flipDuration |
number |
320 |
Milliseconds for a single half-flap rotation. |
intensity |
number |
1 |
Multiplier on the per-cell stagger. >1 longer cascade, <1 punchier. |
direction |
'forward' | 'shortest' |
'forward' |
Per-cell traversal: always forward, or whichever way is fewer ticks. |
size |
'sm' | 'md' | 'lg' |
'md' |
Type scale and cell dimensions. |
class |
string |
'' |
Extra classes on the wrapper. |
Edge Cases
| Situation | Behaviour |
|---|---|
value shrinks (fewer cells) |
syncCells re-creates displayed at the new length; orphan timers from the old length are cleared via clearTimers(). |
value grows (more cells) |
New cells start at their target glyph (prev[i] ?? targets[i]); only changed cells animate. |
Glyph not in charset |
buildTickSequence returns [to]; the cell does a single flap straight to the target. |
direction = 'shortest' and equidistant |
Forward wins on tie (forward <= backward). |
User has prefers-reduced-motion: reduce |
scheduleCell writes the target directly, skipping every intermediate tick. |
| Reduced-motion preference changes mid-mount | mq.addEventListener('change') updates prefersReduced; the next value change uses the new mode. |
| Component unmounts mid-flip | clearTimers() cancels every pending setTimeout; the cleanup also unbinds the matchMedia listener. |
value = '' |
cells = []; nothing renders, aria-busy is false. |
Dependencies
- Svelte 5.x β
$state,$derived,onMount,untrack. window.matchMedia(native) β reduced-motion gate, with feature-detection.- Zero external dependencies otherwise. Asset-free.
File Structure
src/lib/components/SplitFlap.svelte # implementation
src/lib/components/SplitFlap.md # this file (rendered inside ComponentPageShell)
src/lib/components/SplitFlap.test.ts # vitest unit tests for the pure helpers
src/routes/splitflap/+page.svelte # demo pageAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
value | string | required | Target string. Bind to a reactive source for live updates. |
charset | "alnum" | "alpha" | "digits" | "solari" | "alnum" | Glyph set traversed during the flip. |
stagger | number | 60 | Per-cell start delay in ms. |
flipDuration | number | 320 | Time per single flip in ms. |
intensity | number | 1 | Multiplies stagger; 0.5 snappier, 1.6 luxurious. |
direction | "forward" | "shortest" | "forward" | forward traverses charset; shortest picks min flips per cell. |
size | "sm" | "md" | "lg" | "md" | Cell size preset. |
class | string | "" | Extra class on the wrapper. |