Split Flap

An airport departure board: each cell folds through the drum with a two-half 3D flap and rows settle in a cascade.

  • Text Effects
  • Travel
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (3D transforms). Reduced motion swaps characters instantly.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/split-flap.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/text/split-flap.tsx
1/**
2 * SplitFlap — an airport departure board. Every cell is a split-flap unit
3 * with a top and bottom half; when its character changes it flips through
4 * the drum (in alphabet order, like the real thing) with a two-half 3D fold,
5 * and cells settle in a staggered cascade.
6 *
7 * Exports
8 * - <SplitFlap value length? charset? stagger? flipMs? /> one row of cells.
9 * - <FlapBoard rows columns /> a whole board.
10 *
11 * SplitFlap props
12 * - value: string Text to show. Uppercased, padded/clipped to `length`.
13 * - length?: number Number of cells. Default value.length.
14 * - charset?: string Drum order. Default " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/".
15 * - flipMs?: number Duration of one flap in ms. Default 90.
16 * - maxSteps?: number Max flaps per change (long trips skip ahead). Default 14.
17 * - stagger?: number ms delay per cell from left. Default 24.
18 * - delay?: number Extra ms before this row starts. Default 0.
19 * - tone?: string Character colour, e.g. a status colour.
20 * - label?: string Accessible text (defaults to the trimmed value).
21 * CSS vars on .fd-sf root: --fd-sf-w, --fd-sf-h (cell size), --fd-sf-bg, --fd-sf-fg, --fd-sf-gap.
22 *
23 * Accessibility: each row is one text node for assistive tech (role="text"
24 * fallback via a visually hidden copy); flaps are aria-hidden. Wrap a board in
25 * a <table> (as in FlapBoard) so screen readers can navigate it.
26 * Reduced motion: cells change instantly.
27 * Browser support: all evergreen browsers (3D transforms, backface-visibility).
28 */
29import { useEffect, useRef, useState } from "react";
30import type { CSSProperties } from "react";
31
32const DRUM = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/";
33
34export interface SplitFlapProps {
35 value: string;
36 length?: number;
37 charset?: string;
38 flipMs?: number;
39 maxSteps?: number;
40 stagger?: number;
41 delay?: number;
42 tone?: string;
43 label?: string;
44 className?: string;
45 style?: CSSProperties;
46}
47
48/** One split-flap cell. All animation is imperative — React renders it once per target char. */
49function Cell({ target, charset, flipMs, maxSteps, delay }: { target: string; charset: string; flipMs: number; maxSteps: number; delay: number }) {
50 const el = useRef<HTMLSpanElement>(null);
51 const shown = useRef(target);
52 const timer = useRef(0);
53
54 useEffect(() => {
55 const root = el.current;
56 if (!root) return;
57 const q = (s: string) => root.querySelector<HTMLElement>(s)!;
58 const top = q(".fd-sf-top b");
59 const bot = q(".fd-sf-bot b");
60 const fTop = q(".fd-sf-ftop b");
61 const fBot = q(".fd-sf-fbot b");
62 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
63 window.clearTimeout(timer.current);
64 if (reduced || shown.current === target) {
65 shown.current = target;
66 top.textContent = bot.textContent = fTop.textContent = fBot.textContent = target;
67 root.removeAttribute("data-flip");
68 return;
69 }
70 // build the path through the drum
71 const from = Math.max(0, charset.indexOf(shown.current));
72 const to = charset.indexOf(target);
73 const path: string[] = [];
74 if (to < 0) path.push(target);
75 else {
76 let i = from;
77 while (i !== to) {
78 i = (i + 1) % charset.length;
79 path.push(charset[i]);
80 }
81 }
82 const seq = path.length > maxSteps ? path.slice(path.length - maxSteps) : path;
83 let k = 0;
84 const step = () => {
85 const next = seq[k];
86 const cur = shown.current;
87 // static halves: top shows next, bottom still shows current; flaps carry the fold
88 top.textContent = next;
89 bot.textContent = cur;
90 fTop.textContent = cur;
91 fBot.textContent = next;
92 // alternate between two identical animations to restart without a reflow
93 root.setAttribute("data-flip", k % 2 ? "b" : "a");
94 shown.current = next;
95 k++;
96 // last flap gets a little extra time to land
97 const d = k >= seq.length ? flipMs * 1.6 : flipMs * (0.85 + Math.random() * 0.3);
98 timer.current = window.setTimeout(() => {
99 bot.textContent = next;
100 if (k < seq.length) step();
101 else root.removeAttribute("data-flip");
102 }, d);
103 };
104 timer.current = window.setTimeout(step, delay);
105 return () => window.clearTimeout(timer.current);
106 }, [target, charset, flipMs, maxSteps, delay]);
107
108 const c = shown.current;
109 return (
110 <span ref={el} className="fd-sf-cell" aria-hidden="true">
111 <span className="fd-sf-top"><b>{c}</b></span>
112 <span className="fd-sf-bot"><b>{c}</b></span>
113 <span className="fd-sf-ftop"><b>{c}</b></span>
114 <span className="fd-sf-fbot"><b>{c}</b></span>
115 </span>
116 );
117}
118
119export function SplitFlap({
120 value,
121 length,
122 charset = DRUM,
123 flipMs = 90,
124 maxSteps = 14,
125 stagger = 24,
126 delay = 0,
127 tone,
128 label,
129 className,
130 style,
131}: SplitFlapProps) {
132 const n = length ?? value.length;
133 const v = value.toUpperCase().padEnd(n, " ").slice(0, n);
134 return (
135 <span className={`fd-sf${className ? " " + className : ""}`} style={{ ["--fd-sf-dur" as string]: `${flipMs}ms`, ...style, ...(tone ? { ["--fd-sf-fg" as string]: tone } : null) }}>
136 <style>{css}</style>
137 <span className="fd-sf-sr">{label ?? value.trim()}</span>
138 {Array.from(v).map((ch, i) => (
139 <Cell key={i} target={ch} charset={charset} flipMs={flipMs} maxSteps={maxSteps} delay={delay + i * stagger} />
140 ))}
141 </span>
142 );
143}
144
145const css = `
146.fd-sf,.fd-sf *,.fd-sf *::before,.fd-sf *::after{box-sizing:border-box}
147.fd-sf{--fd-sf-w:1em;--fd-sf-h:1.42em;--fd-sf-gap:.08em;--fd-sf-bg:#1A1B1F;--fd-sf-bg2:#141518;--fd-sf-fg:#F2EFE6;--fd-sf-dur:70ms;
148 display:inline-flex;gap:var(--fd-sf-gap);margin:0;padding:0;vertical-align:middle;
149 font:600 1rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-sf-fg)}
150.fd-sf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
151.fd-sf-cell{position:relative;display:block;flex:none;width:var(--fd-sf-w);height:var(--fd-sf-h);margin:0;padding:0;perspective:5em;border-radius:.16em;
152 box-shadow:0 .06em .12em rgba(0,0,0,.5),inset 0 0 0 1px rgba(255,255,255,.03)}
153.fd-sf-top,.fd-sf-bot,.fd-sf-ftop,.fd-sf-fbot{position:absolute;left:0;right:0;height:50%;margin:0;padding:0;overflow:hidden;backface-visibility:hidden;-webkit-backface-visibility:hidden}
154.fd-sf-top,.fd-sf-ftop{top:0;border-radius:.16em .16em 0 0;background:linear-gradient(180deg,var(--fd-sf-bg),var(--fd-sf-bg2))}
155.fd-sf-bot,.fd-sf-fbot{bottom:0;border-radius:0 0 .16em .16em;background:linear-gradient(180deg,var(--fd-sf-bg2),var(--fd-sf-bg))}
156.fd-sf-top::after,.fd-sf-ftop::after{content:"";position:absolute;left:0;right:0;bottom:0;height:1px;background:rgba(0,0,0,.65)}
157.fd-sf-cell b{position:absolute;left:0;right:0;display:block;height:var(--fd-sf-h);margin:0;padding:0;font:inherit;line-height:var(--fd-sf-h);text-align:center;color:inherit;white-space:pre}
158.fd-sf-top b,.fd-sf-ftop b{top:0}
159.fd-sf-bot b,.fd-sf-fbot b{bottom:0}
160.fd-sf-ftop{transform-origin:50% 100%;transform:rotateX(0deg);z-index:2;visibility:hidden}
161.fd-sf-fbot{transform-origin:50% 0%;transform:rotateX(90deg);z-index:2;visibility:hidden}
162.fd-sf-cell[data-flip] .fd-sf-ftop,.fd-sf-cell[data-flip] .fd-sf-fbot{visibility:visible}
163.fd-sf-cell[data-flip="a"] .fd-sf-ftop{animation:fd-sf-ta calc(var(--fd-sf-dur) * .5) cubic-bezier(.55,0,.9,.4) both}
164.fd-sf-cell[data-flip="b"] .fd-sf-ftop{animation:fd-sf-tb calc(var(--fd-sf-dur) * .5) cubic-bezier(.55,0,.9,.4) both}
165.fd-sf-cell[data-flip="a"] .fd-sf-fbot{animation:fd-sf-ba calc(var(--fd-sf-dur) * .5) cubic-bezier(.1,.6,.4,1) calc(var(--fd-sf-dur) * .5) both}
166.fd-sf-cell[data-flip="b"] .fd-sf-fbot{animation:fd-sf-bb calc(var(--fd-sf-dur) * .5) cubic-bezier(.1,.6,.4,1) calc(var(--fd-sf-dur) * .5) both}
167@keyframes fd-sf-ta{from{transform:rotateX(0);filter:brightness(1)}to{transform:rotateX(-90deg);filter:brightness(.55)}}
168@keyframes fd-sf-tb{from{transform:rotateX(0);filter:brightness(1)}to{transform:rotateX(-90deg);filter:brightness(.55)}}
169@keyframes fd-sf-ba{from{transform:rotateX(90deg);filter:brightness(1.35)}to{transform:rotateX(0);filter:brightness(1)}}
170@keyframes fd-sf-bb{from{transform:rotateX(90deg);filter:brightness(1.35)}to{transform:rotateX(0);filter:brightness(1)}}
171@media (prefers-reduced-motion:reduce){.fd-sf-cell[data-flip] .fd-sf-ftop,.fd-sf-cell[data-flip] .fd-sf-fbot{animation:none;visibility:hidden}}
172`;
173
174/* ───────────────────────── board ───────────────────────── */
175
176export type FlapColumn = { key: string; label: string; length: number; align?: "start" | "end" };
177export type FlapRow = Record<string, string> & { id: string; tone?: string };
178
179export function FlapBoard({ columns, rows, caption, className }: { columns: FlapColumn[]; rows: FlapRow[]; caption?: string; className?: string }) {
180 return (
181 <table className={`fd-sf-board${className ? " " + className : ""}`}>
182 <style>{boardCss}</style>
183 {caption && <caption className="fd-sf-sr">{caption}</caption>}
184 <thead>
185 <tr>
186 {columns.map((c) => (
187 <th key={c.key} scope="col">{c.label}</th>
188 ))}
189 </tr>
190 </thead>
191 <tbody>
192 {rows.map((r, ri) => (
193 <tr key={r.id}>
194 {columns.map((c, ci) => {
195 const raw = r[c.key] ?? "";
196 const val = c.align === "end" ? raw.padStart(c.length, " ") : raw;
197 return (
198 <td key={c.key}>
199 <SplitFlap value={val} label={raw} length={c.length} delay={ri * 90 + ci * 60} tone={c.key === "status" ? r.tone : undefined} />
200 </td>
201 );
202 })}
203 </tr>
204 ))}
205 </tbody>
206 </table>
207 );
208}
209
210const boardCss = `
211.fd-sf-board{border-collapse:separate;border-spacing:0;margin:0;padding:0;border:0;background:none;color:inherit;font:inherit}
212.fd-sf-board th{margin:0;padding:0 .9em .7em 0;border:0;text-align:left;font:600 .62em/1 ui-sans-serif,-apple-system,"SF Pro Text","Inter",sans-serif;
213 letter-spacing:.14em;text-transform:uppercase;color:#C9A227;white-space:nowrap}
214.fd-sf-board td{margin:0;padding:0 .9em .3em 0;border:0;vertical-align:middle}
215.fd-sf-board th:last-child,.fd-sf-board td:last-child{padding-right:0}
216.fd-sf-board .fd-sf-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
217`;
218
219/* ───────────────────────────── demo ───────────────────────────── */
220
221const COLS: FlapColumn[] = [
222 { key: "time", label: "Time", length: 5 },
223 { key: "dest", label: "Destination", length: 12 },
224 { key: "flight", label: "Flight", length: 6 },
225 { key: "gate", label: "Gate", length: 3 },
226 { key: "status", label: "Remarks", length: 9 },
227];
228
229const AMBER = "#FFC53D";
230const GREEN = "#6EE7A8";
231const RED = "#FF7A6B";
232
233const BOARD_A: FlapRow[] = [
234 { id: "1", time: "14:05", dest: "LISBON", flight: "WV 412", gate: "B12", status: "BOARDING", tone: GREEN },
235 { id: "2", time: "14:20", dest: "REYKJAVIK", flight: "NA 220", gate: "C04", status: "ON TIME" },
236 { id: "3", time: "14:35", dest: "MARRAKECH", flight: "WV 918", gate: "A21", status: "DELAYED", tone: AMBER },
237 { id: "4", time: "14:50", dest: "OSAKA KIX", flight: "SK 061", gate: "D02", status: "ON TIME" },
238 { id: "5", time: "15:10", dest: "CAPE TOWN", flight: "WV 731", gate: "B07", status: "ON TIME" },
239 { id: "6", time: "15:25", dest: "NEW YORK JFK", flight: "AT 104", gate: "E15", status: "GATE OPEN" },
240];
241const BOARD_B: FlapRow[] = [
242 { id: "1", time: "14:05", dest: "LISBON", flight: "WV 412", gate: "B12", status: "CLOSED", tone: RED },
243 { id: "2", time: "14:20", dest: "REYKJAVIK", flight: "NA 220", gate: "C04", status: "BOARDING", tone: GREEN },
244 { id: "3", time: "14:55", dest: "MARRAKECH", flight: "WV 918", gate: "A19", status: "GATE CHG", tone: AMBER },
245 { id: "4", time: "14:50", dest: "OSAKA KIX", flight: "SK 061", gate: "D02", status: "GATE OPEN" },
246 { id: "5", time: "15:10", dest: "CAPE TOWN", flight: "WV 731", gate: "B07", status: "ON TIME" },
247 { id: "6", time: "15:25", dest: "NEW YORK JFK", flight: "AT 104", gate: "E15", status: "GATE OPEN" },
248];
249
250export function Demo() {
251 const [b, setB] = useState(0);
252 const [clock, setClock] = useState("13:58");
253 useEffect(() => {
254 const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
255 const first = window.setTimeout(() => setB(1), 5200);
256 if (reduced) return () => window.clearTimeout(first);
257 const id = window.setInterval(() => setB((x) => (x + 1) % 2), 9000);
258 return () => {
259 window.clearTimeout(first);
260 window.clearInterval(id);
261 };
262 }, []);
263 useEffect(() => setClock(b ? "14:02" : "13:58"), [b]);
264 const rows = b ? BOARD_B : BOARD_A;
265
266 return (
267 <div className="fd-sf-demo">
268 <style>{demoCss}</style>
269 <header className="fd-sf-demo-bar">
270 <span className="fd-sf-demo-brand">
271 <svg width="22" height="22" viewBox="0 0 24 24" aria-hidden="true"><path d="M2.5 13.5l7-1.6 5.2-7.4h2.6l-2.8 7 5-1.1c1.4-.3 2.3 1.6.9 2.3l-14.9 4.4-3-3.6z" fill="#FFC53D" /></svg>
272 Wayvia
273 </span>
274 <span className="fd-sf-demo-meta">Terminal 2 · Departures</span>
275 <button type="button" className="fd-sf-demo-btn" onClick={() => setB((x) => (x + 1) % 2)}>Update board</button>
276 </header>
277
278 <main className="fd-sf-demo-main">
279 <div className="fd-sf-demo-title">
280 <div>
281 <p className="fd-sf-demo-eyebrow">Your trip · Sat 14 Nov</p>
282 <h1 className="fd-sf-demo-h1">Departures</h1>
283 </div>
284 <div className="fd-sf-demo-clock" aria-label={`Local time ${clock}`}>
285 <SplitFlap value={clock} className="fd-sf-demo-clockflap" stagger={60} />
286 </div>
287 </div>
288
289 <div className="fd-sf-demo-board">
290 <div className="fd-sf-demo-scroll">
291 <FlapBoard columns={COLS} rows={rows} caption="Departures, Terminal 2" className="fd-sf-demo-table" />
292 </div>
293 </div>
294
295 <div className="fd-sf-demo-trip">
296 <div className="fd-sf-demo-pass">
297 <span className="fd-sf-demo-k">Your flight</span>
298 <span className="fd-sf-demo-v">WV 918 · Marrakech</span>
299 </div>
300 <div className="fd-sf-demo-pass">
301 <span className="fd-sf-demo-k">Gate</span>
302 <span className="fd-sf-demo-v">{b ? "A19 · 6 min walk" : "A21 · 7 min walk"}</span>
303 </div>
304 <div className="fd-sf-demo-pass">
305 <span className="fd-sf-demo-k">Seat</span>
306 <span className="fd-sf-demo-v">14A · Window</span>
307 </div>
308 </div>
309 </main>
310 </div>
311 );
312}
313
314const demoCss = `
315.fd-sf-demo,.fd-sf-demo *,.fd-sf-demo *::before,.fd-sf-demo *::after{box-sizing:border-box}
316.fd-sf-demo{--d-font:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;--d-disp:"SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;
317 margin:0;padding:0;min-height:100vh;background:radial-gradient(120% 80% at 50% 0%,#1B1C21 0%,#0C0D10 60%);color:#F2EFE6;font:400 15px/1.5 var(--d-font);-webkit-font-smoothing:antialiased}
318.fd-sf-demo-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0;padding:20px clamp(16px,4vw,48px)}
319.fd-sf-demo-brand{display:inline-flex;align-items:center;gap:10px;margin:0;font:600 17px/1 var(--d-disp);letter-spacing:-.01em}
320.fd-sf-demo-brand svg{display:block}
321.fd-sf-demo-meta{margin:0;color:rgba(242,239,230,.55);font:500 13px/1 var(--d-font)}
322.fd-sf-demo-btn{appearance:none;-webkit-appearance:none;margin:0;height:36px;padding:0 16px;border:0;border-radius:9999px;background:#FFC53D;color:#1A1405;font:600 13px/1 var(--d-font);cursor:pointer;
323 transition:transform 120ms cubic-bezier(.22,1,.36,1)}
324.fd-sf-demo-btn:active{transform:scale(.96)}
325.fd-sf-demo-btn:focus-visible{outline:2px solid rgba(10,132,255,.8);outline-offset:2px}
326.fd-sf-demo-main{max-width:1280px;margin:0 auto;padding:clamp(24px,5vh,56px) clamp(16px,4vw,48px) 48px}
327.fd-sf-demo-title{display:flex;align-items:flex-end;justify-content:space-between;gap:24px;margin:0 0 28px}
328.fd-sf-demo-eyebrow{margin:0 0 8px;color:rgba(242,239,230,.55);font:500 13px/1 var(--d-font)}
329.fd-sf-demo-h1{margin:0;font:600 clamp(32px,4.4vw,56px)/1 var(--d-disp);letter-spacing:-.035em;color:#F2EFE6}
330.fd-sf-demo-clockflap{font-size:clamp(22px,2.6vw,32px);--fd-sf-fg:#FFC53D}
331.fd-sf-demo-board{margin:0;padding:clamp(16px,2.4vw,28px);border-radius:24px;background:linear-gradient(180deg,#0A0A0C,#060607);
332 box-shadow:inset 0 1px 0 rgba(255,255,255,.06),inset 0 0 0 1px rgba(255,255,255,.04),0 40px 80px -40px rgba(0,0,0,.8)}
333.fd-sf-demo-scroll{overflow-x:auto;margin:0;padding:0 0 4px;scrollbar-width:thin}
334.fd-sf-demo-table{font-size:clamp(15px,2.05vw,27px)}
335.fd-sf-demo-trip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;margin:20px 0 0}
336.fd-sf-demo-pass{display:flex;flex-direction:column;gap:6px;margin:0;padding:16px 18px;border-radius:18px;background:rgba(255,255,255,.04);box-shadow:inset 0 0 0 1px rgba(255,255,255,.06)}
337.fd-sf-demo-k{margin:0;color:rgba(242,239,230,.5);font:500 12px/1 var(--d-font);text-transform:uppercase;letter-spacing:.08em}
338.fd-sf-demo-v{margin:0;color:#F2EFE6;font:500 17px/1.25 var(--d-font);letter-spacing:-.01em}
339@media (max-width:720px){.fd-sf-demo-meta{display:none}.fd-sf-demo-trip{grid-template-columns:1fr}.fd-sf-demo-table{font-size:14px}}
340`;

More Text Effects. Same file contract.

Split Text

Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.

Text Morph

Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.

Text Repel

A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.