Split Text
Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
An airport departure board: each cell folds through the drum with a two-half 3D flap and rows settle in a cascade.
Browser support · All evergreen browsers (3D transforms). Reduced motion swaps characters instantly.
npx shadcn@latest add https://freedesignmd.com/r/split-flap.json1/**2 * SplitFlap — an airport departure board. Every cell is a split-flap unit3 * with a top and bottom half; when its character changes it flips through4 * 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 * Exports8 * - <SplitFlap value length? charset? stagger? flipMs? /> one row of cells.9 * - <FlapBoard rows columns /> a whole board.10 *11 * SplitFlap props12 * - 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 in25 * 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 drum71 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 fold88 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 reflow93 root.setAttribute("data-flip", k % 2 ? "b" : "a");94 shown.current = next;95 k++;96 // last flap gets a little extra time to land97 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 Wayvia273 </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`;Split any string into chars, words or lines and reveal it with rise, wave, scatter, blur or a human-paced typewriter.
Cycle through words with a letter-level morph: shared letters glide into place, the rest blur away, the line stays centred.
A headline whose letters drift away from — or toward — your cursor and spring home, each on its own spring.