Split-Flap Board
A departure board in CSS 3D: every flap turns forward through its drum until it lands on its letter, four half-cards and two hinged leaves per character.
<SplitFlapBoard flipMs={70} /> · advanceBoard() every 70 ms, 30 ms a column
man split-flap
A line ref opens it in the source below
Name
split-flap — a departure board where every character is four half-cards on one hinge.
How it works
A drum, not a font. Each flap owns a drum of 42 glyphs (blank, A–Z, 0–9,
. / - _ ❯) that only turns forward: its step count is its distance to the target on that drum, wrapping past the end.Four half-cards. Two static halves show the new glyph on top and the old one below; two leaves hinged on the middle line carry the halves in motion, each with
backface-visibility: hidden, so a leaf seen edge-on disappears.Half a flip each. The top leaf falls from 0 to −90° in the first half of the flip time, the bottom leaf lands from 90° to 0 in the second. Every step swaps the flap between two identical pairs of keyframes, and a new animation name restarts the animation.
A ripple, not a wall. Columns start one stagger apart and the second line two columns later, so a new message ripples across the board.
Cost
Transforms only. Only the two leaves and a shade animate, on
transformandopacity; a flap re-renders only when its own glyph changes (memo).Stops when it lands. One timer for the whole board, and none once every flap shows its target; under
prefers-reduced-motionthe letters are set at once.
See also
- computer-3d — the same CSS 3D, twelve faces instead of four.
import { assignInlineVars } from "@vanilla-extract/dynamic";
import { memo } from "react";
import { BOARD_COLS, DRUM, type BoardState } from "./split-flap-drum";
import * as styles from "./split-flap-board.css";
// The board's terminal marks print in gold, like the site's `_❯`.
const isMark = (glyph: string) => glyph === "_" || glyph === "❯";
interface HalfProps {
glyph: string;
className: string;
/** Which half of the glyph this card shows. */
half: "top" | "bottom";
}
function Half({ glyph, className, half }: HalfProps) {
return (
<span className={className}>
<span className={styles.glyph} data-half={half} data-mark={isMark(glyph) || undefined}>
{glyph}
</span>
</span>
);
}
interface FlapProps {
current: number;
previous: number;
flips: number;
/** Steps from where the message started to its target, printed under the flap in xray. */
steps?: number;
moving: boolean;
}
/**
* One character: four half-cards. The static halves show the new glyph on top and the old one
* below; the two leaves, hinged on the middle line, carry the old top down and the new bottom
* in. Each step flips the parity, which swaps the leaves to an identical pair of keyframes and
* so restarts them.
*/
const Flap = memo(function Flap({ current, previous, flips, steps, moving }: FlapProps) {
const next = DRUM[current];
const old = DRUM[previous];
const parity = flips === 0 ? undefined : flips % 2 === 0 ? "b" : "a";
return (
<span className={styles.cell}>
<span className={styles.flap} data-parity={parity}>
<Half glyph={next} half="top" className={styles.staticTop} />
<span className={styles.staticBottom}>
<span className={styles.glyph} data-half="bottom" data-mark={isMark(old) || undefined}>
{old}
</span>
<span className={styles.shade} />
</span>
<Half glyph={old} half="top" className={styles.leafTop} />
<Half glyph={next} half="bottom" className={styles.leafBottom} />
<span className={styles.hinge} />
</span>
{steps !== undefined && (
<span className={styles.steps} data-moving={moving || undefined}>
{String(steps).padStart(2, "0")}
</span>
)}
</span>
);
});
export interface SplitFlapBoardProps {
state: BoardState;
targets: readonly number[];
flipMs: number;
/** Per flap, steps from the message's start to its target: shown under the flaps (xray). */
steps?: readonly number[];
/** The lines as they read once landed, for assistive tech (the flaps are decorative). */
label: string;
}
/** A departure board in CSS 3D: rows of flaps, sized on the board's own width. */
export function SplitFlapBoard({ state, targets, flipMs, steps, label }: SplitFlapBoardProps) {
const rows = Math.ceil(state.current.length / BOARD_COLS);
return (
<div
className={styles.board}
style={assignInlineVars({ [styles.flipDuration]: `${flipMs}ms` })}
>
<span className={styles.srOnly}>{label}</span>
{Array.from({ length: rows }, (_, row) => (
<span key={row} className={styles.row} aria-hidden="true">
{state.current.slice(row * BOARD_COLS, (row + 1) * BOARD_COLS).map((current, col) => {
const index = row * BOARD_COLS + col;
return (
<Flap
// A flap is its position on the board.
// oxlint-disable-next-line react/no-array-index-key
key={index}
current={current}
previous={state.previous[index]}
flips={state.flips[index]}
steps={steps?.[index]}
moving={current !== targets[index]}
/>
);
})}
</span>
))}
</div>
);
}