Split-flap board
A station departure board: each character is a pair of flaps that falls to the next value, column by column. The text is the truth and changes are announced once per row. For deploy boards, queues and live status.
A deploy board
Each region's status flips over as its rollout moves on.
Installation
Add with the CLI
Once per project, add the Bevel registry to
components.json.components.json { "registries": { "@bevel": "https://bevel-ui.vercel.app/r/{name}.json" } }Add Split-flap board. This brings in the Bevel foundation, installs cn, and merges the styles into your global CSS.
Terminal npx shadcn@latest add @bevel/split-flap
Install manually
Install the dependencies.
Terminal pnpm add cnAdd the Bevel foundation, if you haven't already. Light, depth, materials, motion and shape tokens, plus the utilities components use to apply them.
styles/bevel.css /* * Bevel foundation. Every component builds on these tokens and utilities; * component stylesheets only add their own palettes and shapes. * * Layers: * 1. Light one source, above and slightly in front of the screen. * 2. Depth named elevations built from the light: recessed, flush, * raised, pressed, object. * 3. Material a surface recipe (leather, plastic, metal) that reads * --bevel-material and --bevel-material-edge. * 4. Motion two curves, a spring, and durations named by purpose. * 5. Shape radii, stitching, and the type scale for amounts. */ :root { /* 1. Light */ --bevel-highlight: oklch(1 0 0 / 0.22); --bevel-highlight-strong: oklch(1 0 0 / 0.35); --bevel-shade: oklch(0 0 0 / 0.32); --bevel-shade-strong: oklch(0 0 0 / 0.5); --bevel-cast: oklch(0.25 0.03 50 / 0.22); /* Stamped into a surface: shaded on the upper inner edge, lit below. */ --bevel-deboss: 0 -1px 0 oklch(0 0 0 / 0.45), 0 1px 0 oklch(1 0 0 / 0.12); /* 3. Material defaults; components and variants override these. */ --bevel-material: oklch(0.47 0.085 52); --bevel-material-edge: oklch(0.3 0.06 44); --bevel-material-recess: oklch(0.2 0.008 260); /* neutral dark; materials set their own */ --bevel-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.7' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .7 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23g)'/%3E%3C/svg%3E"); --bevel-grain-size: 180px; /* 2. Depth */ --bevel-depth-recessed: inset 0 1px 2px var(--bevel-shade-strong), 0 1px 0 var(--bevel-highlight); --bevel-depth-flush: inset 0 1px 0 var(--bevel-highlight), inset 0 -1px 0 var(--bevel-shade); --bevel-depth-raised: inset 0 1px 0 var(--bevel-highlight), 0 1px 0 var(--bevel-material-edge), 0 2px 0 var(--bevel-material-edge), 0 3px 5px var(--bevel-shade); --bevel-depth-pressed: inset 0 1px 2px var(--bevel-shade), 0 1px 0 var(--bevel-highlight); /* A whole object resting on the page. */ --bevel-depth-object: inset 0 1px 0 var(--bevel-highlight), inset 0 -1px 0 var(--bevel-shade), 0 0 0 1px var(--bevel-material-edge), 0 1px 1px var(--bevel-cast), 0 6px 12px -2px var(--bevel-cast), 0 18px 32px -8px var(--bevel-cast); /* The shadow under a loose object, for filter: drop-shadow(). */ --bevel-depth-cast-sm: 0 4px 6px var(--bevel-cast); /* A thin object (a card) lying on another surface. */ --bevel-depth-sheet: inset 0 1px 0 var(--bevel-highlight-strong), inset 0 0 0 1px oklch(0 0 0 / 0.12), 0 -1px 2px oklch(0 0 0 / 0.18); /* 4. Motion */ --bevel-ease-out: cubic-bezier(0.23, 1, 0.32, 1); --bevel-ease-in-out: cubic-bezier(0.77, 0, 0.175, 1); /* Underdamped spring, ~8% overshoot. Pure CSS; pair with --bevel-duration-move or longer. */ --bevel-ease-spring: linear(0, 0.06, 0.202, 0.38, 0.561, 0.723, 0.856, 0.955, 1.023, 1.062, 1.081, 1.083, 1.075, 1.062, 1.047, 1.032, 1.019, 1.009, 1.002, 0.997, 0.994, 0.993, 0.993, 0.994, 1); --bevel-duration-press: 100ms; /* a control giving under a finger */ --bevel-duration-small: 200ms; /* hover lifts, tabs, small state changes */ --bevel-duration-move: 300ms; /* objects moving: cards, drawers */ --bevel-duration-ambient: 520ms; /* whole-scene changes: lighting */ --bevel-stagger: 30ms; --bevel-lift: 3px; /* how far an object rises on hover */ --bevel-perspective: 30rem; /* viewing distance for parts that turn in 3D (covers, flaps) */ /* Unchosen items in a group sit back in shadow. */ --bevel-dim: 0.82; /* 5. Shape */ --bevel-radius-xs: 0.25rem; --bevel-radius-sm: 0.5rem; --bevel-radius-md: 0.625rem; --bevel-radius-lg: 1rem; --bevel-radius-xl: 1.5rem; --bevel-stitch-width: 1.5px; --bevel-stitch-inset: 0.375rem; /* Amounts: the figure, its secondary parts (currency, cents), labels, fine print. */ --bevel-amount: 2.25rem; --bevel-amount-minor: 1.25rem; --bevel-label: 0.75rem; --bevel-fine: 0.6875rem; --bevel-tracking-label: 0.08em; /* stamped labels */ --bevel-tracking-print: 0.06em; /* printed marks on cards */ /* Paper and ink */ --bevel-paper: oklch(0.975 0.008 85); --bevel-paper-foreground: oklch(0.28 0.02 260); --bevel-paper-muted: oklch(0.48 0.015 260); --bevel-paper-rule: oklch(0.78 0.04 240 / 0.45); --bevel-paper-margin: oklch(0.68 0.14 25 / 0.45); --bevel-fibre: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='240'%3E%3Cfilter id='f'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9 .35' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .18 0 0 0 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23f)'/%3E%3C/svg%3E"); /* Ink soaks unevenly: mostly solid with pale specks. Used as a mask. */ --bevel-ink-texture: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='i'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 -2.4 0 0 0 2.1'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23i)'/%3E%3C/svg%3E"); --bevel-ink-red: oklch(0.52 0.19 27); --bevel-ink-blue: oklch(0.44 0.14 262); --bevel-ink-green: oklch(0.5 0.12 155); --bevel-ink-black: oklch(0.3 0.01 260); --bevel-ink-amber: oklch(0.55 0.13 65); /* Brass and ivory: instrument hardware and dial faces */ --bevel-brass: oklch(0.76 0.11 82); --bevel-brass-edge: oklch(0.52 0.08 70); --bevel-ivory: oklch(0.95 0.025 90); --bevel-ivory-foreground: oklch(0.25 0.015 60); --bevel-needle: oklch(0.55 0.2 27); /* Desk stationery: legal pads, pens, pencils, exhibit stickers */ --bevel-pad: oklch(0.95 0.075 98); /* yellow legal pad */ --bevel-pad-foreground: oklch(0.27 0.03 255); --bevel-pad-rule: oklch(0.72 0.06 235 / 0.45); --bevel-pad-margin: oklch(0.62 0.16 25 / 0.55); --bevel-pen-red: oklch(0.52 0.2 27); /* correcting pen */ --bevel-pen-blue: oklch(0.42 0.15 262); /* writing pen */ --bevel-pencil: oklch(0.55 0.01 260); /* not written in ink yet */ --bevel-sticker: oklch(0.9 0.12 95); /* exhibit sticker */ --bevel-sticker-foreground: oklch(0.26 0.03 70); /* Handwriting. Bring a marker font (e.g. Permanent Marker) for the full effect; these are fallbacks. */ --bevel-font-marker: "Permanent Marker", "Marker Felt", "Chalkboard SE", ui-rounded, "Arial Rounded MT Bold", system-ui, sans-serif; } .dark { --bevel-highlight: oklch(1 0 0 / 0.14); /* Paper under dim light, not inverted: it's still paper. */ --bevel-paper: oklch(0.88 0.008 85); --bevel-ivory: oklch(0.86 0.022 90); --bevel-pad: oklch(0.86 0.07 98); --bevel-cast: oklch(0 0 0 / 0.55); } /* Depth */ @utility bevel-recessed { background-color: var(--bevel-material-recess); box-shadow: var(--bevel-depth-recessed); } @utility bevel-flush { box-shadow: var(--bevel-depth-flush); } @utility bevel-raised { background-color: color-mix(in oklch, var(--bevel-material), white 7%); background-image: linear-gradient(to bottom, var(--bevel-highlight), transparent 60%); box-shadow: var(--bevel-depth-raised); } @utility bevel-pressed { background-color: color-mix(in oklch, var(--bevel-material), black 4%); background-image: none; box-shadow: var(--bevel-depth-pressed); } @utility bevel-object { box-shadow: var(--bevel-depth-object); } /* Materials */ @utility bevel-leather { background-color: var(--bevel-material); background-image: var(--bevel-grain), linear-gradient(to bottom, var(--bevel-highlight), transparent 28%, transparent 72%, var(--bevel-shade)); background-size: var(--bevel-grain-size) var(--bevel-grain-size), 100% 100%; background-blend-mode: soft-light, normal; } @utility bevel-plastic { background-color: var(--bevel-material); background-image: linear-gradient(115deg, var(--bevel-highlight-strong), transparent 40%), linear-gradient(to bottom, transparent 25%, oklch(0 0 0 / 0.45)); box-shadow: var(--bevel-depth-sheet); } @utility bevel-metal { background-color: var(--bevel-material); background-image: linear-gradient(to bottom, var(--bevel-highlight-strong), transparent 45%, var(--bevel-shade) 100%), repeating-linear-gradient(90deg, oklch(1 0 0 / 0.06) 0 1px, transparent 1px 3px); box-shadow: inset 0 0 0 1px oklch(0 0 0 / 0.25), inset 0 1px 0 var(--bevel-highlight-strong); } /* Details */ @utility bevel-deboss { text-shadow: var(--bevel-deboss); } @utility bevel-stitch { border-color: var(--bevel-stitch, oklch(0.88 0.05 82 / 0.8)); border-style: dashed; border-width: var(--bevel-stitch-width); filter: drop-shadow(0 1px 0 var(--bevel-shade)); } /* A lit fill sitting in a recess (meters, progress). Colour from --bevel-fill. */ @utility bevel-fill { background-color: var(--bevel-fill); background-image: linear-gradient(to bottom, oklch(1 0 0 / 0.55), transparent 55%, oklch(0 0 0 / 0.18)); box-shadow: 0 0 6px -1px var(--bevel-fill); } /* A small indicator light. Colour from --bevel-led. */ @utility bevel-led { background-color: var(--bevel-led); background-image: radial-gradient(circle at 35% 30%, oklch(1 0 0 / 0.85), transparent 45%); box-shadow: 0 0 6px var(--bevel-led), inset 0 -1px 1px oklch(0 0 0 / 0.35), 0 1px 0 var(--bevel-highlight); } /* Paper: a sheet resting on the surface, with fibre in it. */ @utility bevel-paper { background-color: var(--bevel-paper); background-image: var(--bevel-fibre); background-size: 240px 240px; color: var(--bevel-paper-foreground); box-shadow: 0 1px 1px var(--bevel-cast), 0 6px 14px -4px var(--bevel-cast); } /* Ink on paper: multiplies into the surface and soaks unevenly. Colour from --bevel-ink. */ @utility bevel-ink { color: var(--bevel-ink, var(--bevel-ink-red)); border-color: currentColor; mix-blend-mode: multiply; mask-image: var(--bevel-ink-texture); mask-size: 160px 160px; } /* Glass over an instrument window: one reflection from the light above. Put on an overlay element. */ @utility bevel-glass { pointer-events: none; background-image: linear-gradient(165deg, var(--bevel-highlight-strong) 0%, transparent 38%, transparent 70%, var(--bevel-shade) 100%); } /* Material families: one per screen. Set on the screen's container. */ [data-bevel-family="leather"] { --bevel-cast: oklch(0.25 0.03 50 / 0.22); } [data-bevel-family="paper"] { --bevel-cast: oklch(0.3 0.01 250 / 0.16); } .dark :is([data-bevel-family="leather"], [data-bevel-family="paper"]) { --bevel-cast: oklch(0 0 0 / 0.55); } /* * Ruled paper: rules every --bevel-rule-height and a margin line at * --bevel-rule-margin. Set --bevel-sheet for the paper colour. The ruling * scrolls with the content. */ @utility bevel-ruled { background-color: var(--bevel-sheet, var(--bevel-paper)); background-image: linear-gradient( to right, transparent calc(var(--bevel-rule-margin) - 1px), var(--bevel-sheet-margin, var(--bevel-paper-margin)) calc(var(--bevel-rule-margin) - 1px) calc(var(--bevel-rule-margin) + 1px), transparent calc(var(--bevel-rule-margin) + 1px) ), repeating-linear-gradient( to bottom, transparent 0 calc(var(--bevel-rule-height) - 1px), var(--bevel-sheet-rule, var(--bevel-paper-rule)) calc(var(--bevel-rule-height) - 1px) var(--bevel-rule-height) ), var(--bevel-fibre); background-size: 100% 100%, 100% 100%, 240px 240px; background-attachment: local; color: var(--bevel-paper-foreground); box-shadow: 0 1px 1px var(--bevel-cast), 0 6px 14px -4px var(--bevel-cast); }Copy
split-flap.tsxintocomponents/ui.components/split-flap.tsx "use client" import * as React from "react" import { cn } from "cn" type SplitFlapContextValue = { changed: (row: string) => void } const SplitFlapContext = React.createContext<SplitFlapContextValue | null>(null) const SplitFlapRowContext = React.createContext<string>("") function toMilliseconds(value: string, fallback: number) { const n = parseFloat(value) if (Number.isNaN(n)) return fallback return value.trim().endsWith("ms") ? n : n * 1000 } /** * A split-flap status board. The text is the truth; the flaps only show * it. Changes are gathered and announced once, by row, not letter by * letter. Give it an aria-label. */ function SplitFlap({ className, children, ...props }: React.ComponentProps<"div">) { const [message, setMessage] = React.useState("") const pending = React.useRef(new Set<string>()) const timer = React.useRef<number>(undefined) const root = React.useRef<HTMLDivElement>(null) const changed = React.useCallback((row: string) => { pending.current.add(row) if (timer.current !== undefined) return const wait = root.current ? toMilliseconds(getComputedStyle(root.current).getPropertyValue("--split-flap-announce-after"), 1200) : 1200 timer.current = window.setTimeout(() => { setMessage(`Updated: ${[...pending.current].join(", ")}.`) pending.current.clear() timer.current = undefined }, wait) }, []) React.useEffect(() => () => window.clearTimeout(timer.current), []) return ( <SplitFlapContext value={{ changed }}> <div ref={root} role="group" data-slot="split-flap" className={cn("bevel-metal bevel-object max-w-full overflow-x-auto rounded-(--bevel-radius-md) p-3", className)} {...props} > <ul className="flex w-max flex-col gap-1.5">{children}</ul> <p role="status" className="sr-only"> {message} </p> </div> </SplitFlapContext> ) } type SplitFlapRowProps = React.ComponentProps<"li"> & { /** How this row is announced when it changes: “BA 117”. */ name: string } /** One line of the board. */ function SplitFlapRow({ className, name, ...props }: SplitFlapRowProps) { return ( <SplitFlapRowContext value={name}> <li data-slot="split-flap-row" className={cn("flex items-center gap-3", className)} {...props} /> </SplitFlapRowContext> ) } type SplitFlapTextProps = Omit<React.ComponentProps<"span">, "children"> & { value: string /** Number of flaps. Shorter text is padded; longer is cut. */ length: number tone?: "white" | "amber" } /** A run of flaps showing one value. Each character flips when it changes, left to right. */ function SplitFlapText({ className, value, length, tone = "white", ...props }: SplitFlapTextProps) { const context = React.useContext(SplitFlapContext) const row = React.useContext(SplitFlapRowContext) const text = value.toUpperCase().slice(0, length).padEnd(length, " ") const [shown, setShown] = React.useState({ text, previous: text, flips: 0 }) if (shown.text !== text) setShown({ text, previous: shown.text, flips: shown.flips + 1 }) const announced = React.useRef(text) React.useEffect(() => { if (announced.current === text) return announced.current = text context?.changed(row) }, [text, row, context]) return ( <span data-slot="split-flap-text" data-tone={tone} className={cn( "flex gap-px font-mono font-bold text-(--split-flap-foreground) data-[tone=amber]:text-(--split-flap-amber)", className )} {...props} > <span className="sr-only">{value}</span> {text.split("").map((char, i) => ( <SplitFlapCell key={i} char={char} previous={shown.previous[i] ?? " "} flip={shown.previous[i] !== char ? shown.flips : 0} index={i} /> ))} </span> ) } function Half({ char, half, className, style }: { char: string; half: "top" | "bottom"; className?: string; style?: React.CSSProperties }) { return ( <span className={cn( "absolute inset-x-0 h-1/2 overflow-hidden backface-hidden", half === "top" ? "top-0 rounded-t-(--bevel-radius-xs) bg-(--split-flap-flap)" : "bottom-0 rounded-b-(--bevel-radius-xs) bg-(--split-flap-flap-lower)", className )} style={style} > <span className={cn( "absolute inset-x-0 flex h-(--split-flap-cell-height) items-center justify-center", half === "top" ? "top-0" : "bottom-0" )} > {char} </span> </span> ) } /** One character: static halves, plus a pair of flaps that fall when it changes. */ function SplitFlapCell({ char, previous, flip, index }: { char: string; previous: string; flip: number; index: number }) { const delay = { "--split-flap-delay": `calc(var(--bevel-stagger) * ${index})` } as React.CSSProperties const flipping = flip > 0 return ( <span aria-hidden data-slot="split-flap-cell" className="relative block h-(--split-flap-cell-height) w-(--split-flap-cell-width) shrink-0 perspective-(--bevel-perspective)" > <Half char={char} half="top" /> {/* Underneath the falling flap, the old lower half shows until the new one lands. */} <Half char={flipping ? previous : char} half="bottom" className="motion-reduce:hidden" /> <Half char={char} half="bottom" className="hidden motion-reduce:block" /> {flipping && ( <React.Fragment key={flip}> <Half char={previous} half="top" className="split-flap-top z-10 motion-reduce:hidden" style={delay} /> <Half char={char} half="bottom" className="split-flap-bottom z-10 motion-reduce:hidden" style={delay} /> {/* Reduced motion: no flaps, the new character fades in. */} <span className="split-flap-fade absolute inset-0 hidden motion-reduce:block"> <Half char={char} half="top" /> <Half char={char} half="bottom" /> </span> </React.Fragment> )} <span className="absolute inset-x-0 top-1/2 z-20 h-px -translate-y-1/2 bg-(--split-flap-hinge)" /> </span> ) } export { SplitFlap, SplitFlapRow, SplitFlapText } export type { SplitFlapRowProps, SplitFlapTextProps }Copy
split-flap.cssintoyour styles.styles/split-flap.css /* * Split-flap board: each character is a pair of flaps on a hinge. When it * changes, the top flap falls to show the new character, column by * column. Builds on bevel.css (metal, motion). */ :root { --split-flap-housing: oklch(0.22 0.01 260); --split-flap-housing-edge: oklch(0.14 0.01 260); --split-flap-flap: oklch(0.2 0.005 260); --split-flap-flap-lower: oklch(0.17 0.005 260); --split-flap-hinge: oklch(0.08 0 0); --split-flap-foreground: oklch(0.95 0.02 90); --split-flap-amber: oklch(0.84 0.16 80); --split-flap-cell-width: 1.15em; --split-flap-cell-height: 1.6em; --split-flap-delay: 0s; /* set per column, for the left-to-right cascade */ --split-flap-announce-after: 1200ms; /* gather changes, then announce once */ } [data-slot="split-flap"] { --bevel-material: var(--split-flap-housing); --bevel-material-edge: var(--split-flap-housing-edge); } @keyframes split-flap-top { to { transform: rotateX(-90deg); } } @keyframes split-flap-bottom { from { transform: rotateX(90deg); } } @keyframes split-flap-fade { from { opacity: 0; } } /* The upper flap falls away, then the lower one lands. */ @utility split-flap-top { transform-origin: bottom; animation: split-flap-top calc(var(--bevel-duration-small) / 2) var(--bevel-ease-in-out) both; animation-delay: var(--split-flap-delay); } @utility split-flap-bottom { transform-origin: top; animation: split-flap-bottom calc(var(--bevel-duration-small) / 2) var(--bevel-ease-out) both; animation-delay: calc(var(--split-flap-delay) + var(--bevel-duration-small) / 2); } @utility split-flap-fade { animation: split-flap-fade var(--bevel-duration-small) var(--bevel-ease-out) both; }Import the stylesheets after Tailwind in your global CSS, foundation first.
globals.css @import "./bevel.css"; @import "./split-flap.css";
Anatomy
Each part is its own component. Leave out the ones you don't need.
<SplitFlap aria-label="Deploys">
<SplitFlapRow name="US East">
<SplitFlapText value="US-EAST" length={8} />
<SplitFlapText value={status} length={8} tone="amber" />
</SplitFlapRow>
</SplitFlap>Parts
| Part | Renders | What it does |
|---|---|---|
| SplitFlap | div (group) + ul | Gathers changes and announces them together: “Updated: US East, EU West.” Give it an aria-label. |
| SplitFlapRow | li | name: how the row is announced when it changes. |
| SplitFlapText | span | value, length (padded or cut), tone white or amber. Reads as its text; the flaps are decoration. With reduced motion, characters crossfade. |