A split-flap display for React — the mechanical departure boards from airports and train stations, where every character flips through the alphabet until it clicks into place.
import { SplitFlapDisplay } from "@ashtom/react-split-flap-display";
import "@ashtom/react-split-flap-display/styles.css";
<SplitFlapDisplay value={1234} digitCount={4} />;Use it for a single number, a multi-row board of text, or a large live-updating display. It can render with DOM, canvas, or WebGL — the canvas and WebGL paths stay at 60fps on large boards (into the thousands of cells) where the DOM renderer drops frames.
What you can build
- Numbers or text — fixed-digit readouts, or multi-row text on a fixed grid, with configurable alignment and alphabet.
- Live updates — update it imperatively through a ref: animate just the line that changed, or commit several lines as one atomic update.
- Board details — gap columns, highlighted columns for a "delayed" flag, an icon slot, and an optional synthesized "clack".
- Theming — size, color, borders, and the flap itself, all through CSS variables.
Try it live in the demo.
pnpm add @ashtom/react-split-flap-displayFor local use before publishing:
pnpm add ../react-split-flapImport the component and styles:
import { SplitFlapDisplay } from "@ashtom/react-split-flap-display";
import "@ashtom/react-split-flap-display/styles.css";<SplitFlapDisplay
value={1234}
digitCount={4}
timing={{ durationMs: 800 }}
/>Values are clamped to the available digits. For digitCount={4}, numbers larger than 9999 render as 9999.
<SplitFlapDisplay
textAlign="center"
rows={[
{ value: "TURN SESSIONS", length: 17, rollEvenWhenSettled: true },
{ value: "INTO A SEARCHABLE", length: 17, rollEvenWhenSettled: true, startDelayMs: 33 },
{ value: "LIBRARY", length: 17, rollEvenWhenSettled: true, startDelayMs: 66 },
]}
timing={{
digitStaggerMs: 11,
launchDelayMs: 1000,
}}
/>Use textAlign="left" or textAlign="right" to keep text flush to an edge, or set textAlign per row in a row config.
verticalAlign positions the text vertically when the board has more rows than the text fills — "top" (default), "center", or "bottom". It keeps the content block (first to last non-blank row) together and redistributes the surrounding blank rows; interior blank rows between content stay put. Pad the rows array with blank rows (or empty values) to give it room to move:
<SplitFlapDisplay
verticalAlign="center"
rows={[
{ value: "", length: 12 },
{ value: "NOW BOARDING", length: 12 },
{ value: "", length: 12 },
]}
/>rollEvenWhenSettled makes cells that already match the target do a full alphabet rotation and land back on the same character. This is useful for padded spaces in headline rows.
Each cell rolls through a fixed alphabet to reach its target character. The
default is " ABCDEFGHIJKLMNOPQRSTUVWXYZ♥0123456789.:" — space, A–Z, a heart,
digits, and .: — and is exported as DEFAULT_ALPHABET. Pass alphabet to
change it, as a string or an array of single characters:
<SplitFlapDisplay
rows={[{ value: "LEVEL 3", length: 7 }]}
alphabet=" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
/>The order matters: cells roll forward through the alphabet in the order you
give, so it also sets the sequence of characters seen during a flip. A target
character that is not in the alphabet renders as the pad character (padChar,
a blank by default).
Set alphabet at the top level to apply it to every row, or per row in a row
config.
Hidden Columns
hiddenColumns renders one or more columns as a narrow empty space (about half a
digit's width) instead of a flap card. A hidden column still consumes a
character of the row's value, so the string positions stay aligned with the
visible cells.
<SplitFlapDisplay
rows={[{ value: "abcde", length: 5, hiddenColumns: [2] }]}
/>With column 2 hidden, "abcde" shows ab then a gap then de — the c falls
into the hidden column and is not drawn. The row length/digitCount must
include the hidden columns, and out-of-range indices are ignored. Set
hiddenColumns per row in a row config, or at the top level to apply it to every
row. The gap width follows the --rsf-hidden-width CSS variable (default
calc(var(--rsf-cell-width) * 0.49)).
highlightColumns recolors the glyph of one or more columns — yellow by
default — to flag something like a delayed flight. The face background is left
as-is (dark in dark mode, light in light mode); the cells still flip and animate
normally, only the text color changes.
<SplitFlapDisplay
rows={[{ value: "BERLIN DELAYED", length: 15, highlightColumns: [8, 9, 10, 11, 12, 13, 14] }]}
/>Set highlightColumns per row in a row config, or at the top level to apply it
to every row. The color comes from a CSS variable, so you can match any theme
(or use it for an on-time green, a boarding color, etc.):
.my-board {
--rsf-highlight-color: #f5c518; /* default: yellow glyph */
}This works across the DOM, canvas, and WebGL renderers.
For displays you drive over time, grab a ref and push values into individual lines. The component animates only the line(s) you change and leaves the rest untouched. Values are fitted to each line's fixed width — text that is too long is cut off rather than wrapped onto another line.
import { useRef } from "react";
import { SplitFlapDisplay, type SplitFlapDisplayHandle } from "@ashtom/react-split-flap-display";
function Board() {
const board = useRef<SplitFlapDisplayHandle>(null);
return (
<>
<SplitFlapDisplay
ref={board}
rows={[
{ value: "", length: 16 },
{ value: "", length: 16 },
{ value: "", length: 16 },
]}
/>
<button onClick={() => board.current?.setLine(1, "GATE A12 — ON TIME")}>
Update one line
</button>
</>
);
}setLine(line, value) rolls a single line. setLines(updates) applies several
line updates as one atomic batch, so they all animate together like a single
committed transaction instead of line-by-line:
board.current?.setLines([
{ line: 0, value: "DEPARTURES" },
{ line: 1, value: "BERLIN 09:45" },
{ line: 2, value: "PARIS 10:20" },
]);Imperatively-set values take precedence over the matching rows/value prop for
that line until they are changed again.
Set sound to play a synthesized split-flap "clack" while cells are flipping —
the mechanical clatter of a real board. It is generated with the Web Audio API
(no audio file to bundle) and globally rate-limited, so even a dense board
clatters pleasantly rather than roaring, on every renderer.
<SplitFlapDisplay value={1234} digitCount={4} sound />Every audible character of the flip — levels, frequencies, density, stereo
spread, reverb, the housing resonance and rattles — is a field of
FlapSoundParams, tunable live via setFlapSoundParams(partial) (defaults in
DEFAULT_FLAP_SOUND_PARAMS). The demo wires a slider to each one under its
Sound settings panel.
Browsers only allow audio to start after a user interaction. If you toggle sound
from a control, call primeFlapSound() inside that click handler to unlock the
audio context up front:
import { SplitFlapDisplay, primeFlapSound } from "@ashtom/react-split-flap-display";
<label>
<input
type="checkbox"
onChange={(e) => {
if (e.currentTarget.checked) primeFlapSound();
setSoundOn(e.currentTarget.checked);
}}
/>
Play sound
</label>function Logo() {
return <svg viewBox="0 0 32 32"><path d="..." /></svg>;
}
<SplitFlapDisplay
value={1234}
digitCount={4}
icon={<Logo />}
iconPlacement={{ cellIndex: 1, beforeChar: "0" }}
/>For multi-row displays, add rowIndex:
iconPlacement={{ rowIndex: 0, cellIndex: 8, beforeChar: "M" }}beforeChar controls where the icon is inserted into the chosen cell's alphabet. For numeric displays, "0" makes the icon appear after the heart and before digits. For text rows that land on letters, place it before a letter that the roll will pass through.
<SplitFlapDisplay
value={42}
digitCount={4}
timing={{
tickMs: 24,
decelerationTailMs: [60, 95, 145, 180],
digitStaggerMs: 55,
durationMs: 600,
iconPauseMs: 1000,
iconTickMultiplier: 10,
launchDelayMs: 0,
rowStaggerMs: 0,
}}
/>The component advances each cell through the alphabet one character at a time. The fast part uses tickMs; the final steps use decelerationTailMs. The CSS flip duration is matched to each step so the flap stays in motion during a roll.
The default CSS uses em sizing, so the display scales with font-size.
.my-wall {
font-size: 4rem;
--rsf-cell-width: 1.067em;
--rsf-cell-height: 1.333em;
--rsf-gap: 0.25em; /* horizontal space between cells */
--rsf-row-gap: 0.6em; /* vertical space between rows */
--rsf-face-bg: #111;
--rsf-face-color: #fff;
--rsf-highlight-color: #f5c518; /* glyph color of highlightColumns cells */
--rsf-glyph-shift: -0.067em; /* vertical nudge to center the glyph on the seam */
--rsf-border: rgb(255 255 255 / 0.16);
--rsf-seam: #050505;
--rsf-seam-lines: 2; /* center-gap height in lines: 2 or 1 */
--rsf-bottom-edge: #050505;
}--rsf-row-gap controls the space between rows (default 0.18em), and the
canvas/WebGL renderers read it from the resolved styles too, so multi-row boards
stay aligned across every renderer.
--rsf-seam-lines sets the height of the center gap between the two flap halves:
2 (default) draws the seam plus a shadow line above it, 1 draws just the seam
for a thinner gap. Every renderer honors it. You can set it in CSS as above, or
with the seamLines prop, which applies across all renderers:
<SplitFlapDisplay value="HELLO" seamLines={1} />--rsf-glyph-shift vertically nudges the DOM glyph so a capital's crossbar sits
on the flap seam. On the client the component measures the actual font at its
real size — baking a reference capital exactly as it will render (size, DPR) and
reading back where the crossbar landed — and sets this automatically. The
canvas/WebGL renderers center from the same kind of measurement, and the seam is
drawn straddling the crossbar, so the gap meets the glyph in every renderer at
every size and in every engine. The CSS value is only an SSR/first-paint
fallback. Override it to re-tune for an unusual font.
Alignment is guarded by a cross-engine test (pnpm test:visual, Playwright over
Chromium / Firefox / WebKit) that screenshots every renderer at multiple sizes —
including the small default — at both gap heights and asserts the crossbar stays
on the seam. See verify/.
Every renderer produces the same split-flap animation, the same public API, and
the same accessible output (role="img" with an aria-label; the flaps are
decorative). They differ only in how the board is drawn, which determines how it
scales as the number of cells grows. Pick one with the renderer prop:
<SplitFlapDisplay rows={rows} renderer="canvas" />renderer |
How it draws | Use it for |
|---|---|---|
"dom" |
One element per flap; the flip is a CSS 3D rotateX, GPU-composited. |
Small/typical boards. Fully CSS-themeable, accessible, smooth at normal sizes. |
"canvas" |
The whole board on one 2D canvas; only changed cells repaint. | Large/dense boards. Flat 60fps regardless of cell count. |
"webgl" (default) |
The whole board as batched quads from one glyph atlas, one draw call per frame. | The densest boards, or to bank headroom. Falls back to canvas if WebGL is unavailable. |
"dom-batched" |
Same as "dom", but coalesces per-frame writes. |
A marginal scripting win over "dom"; same scaling limits. |
"dom-fold" |
Same DOM faces with a flat scaleY fold instead of the 3D rotation. |
Not recommended — see below. |
Choosing for board size. The DOM renderers animate one element per flap, so cost scales with the number of cells; the canvas/WebGL renderers collapse the board to a single layer, so cost is effectively flat. Measured on an Apple M4 (real GPU), replaying a fully-packed board:
| board | dom |
canvas / webgl |
|---|---|---|
| 288 cells (12×24) | ~59fps | 60fps |
| 800 cells (20×40) | ~18fps | 60fps |
| 1800 cells (30×60) | ~2fps | 60fps |
So: the default "webgl" is flat-cost at any board size, and you can switch to
"dom" when you want fully CSS-themeable flap markup on a small board. The
trade-off for the canvas/WebGL paths is that flap styling comes from the
resolved CSS variables at render time rather than live CSS.
Breaking change in 0.2.0: the default
rendererchanged from"dom"to"webgl". If you relied on the DOM renderer's markup or styling hooks (or its SSR markup shape), passrenderer="dom"explicitly to keep the old behavior.
"dom-fold"is kept for completeness but is not a performance option: dropping the 3D context removes the GPU layer promotion that makes the"dom"flip cheap, so it is the slowest renderer at every size. Seedocs/performance-profile.mdanddocs/renderer_plan.mdfor the full comparison.
pnpm install
pnpm test
pnpm devThe demo includes a four-digit number display, a 17-cell three-row headline
variant, and a renderer selector. It accepts ?renderer=, ?rows=, ?cols=,
?icon=0, and ?fill=1 query parameters for sharing a configuration.