Airport-board style display where each character flips through intermediates before settling on the target.
npx shadcn@latest add @paragon/split-flap"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
/** Drum order — each cell walks this ring toward its target character. */
const DRUM = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.,:-+/&%$#";
/**
* The flap sequence for one cell: intermediate characters stepped through
* before landing on the target. Walks the drum ring from the current
* character toward the target; long distances are sampled down to `maxFlips`
* evenly spaced stops so the drum reads as spinning fast. Deterministic —
* no randomness, so replays are identical.
*/
function buildPath(from: string, to: string, maxFlips: number): string[] {
if (from === to) return [];
const n = DRUM.length;
const fromIndex = DRUM.indexOf(from.toUpperCase());
const toIndex = DRUM.indexOf(to.toUpperCase());
const start = fromIndex === -1 ? 0 : fromIndex;
if (toIndex === -1) {
// Target is off the drum — two quick intermediates, then land on it.
return [DRUM[(start + 7) % n], DRUM[(start + 15) % n], to];
}
const distance = (toIndex - start + n) % n || n;
const steps = Math.min(distance, Math.max(1, maxFlips));
const path: string[] = [];
for (let s = 1; s <= steps; s++) {
const index = (start + Math.round((distance * s) / steps)) % n;
// Land on the exact target so original casing is preserved.
path.push(s === steps ? to : DRUM[index]);
}
return path;
}
interface Cell {
char: string;
/** Increments on every character change — keys the flap element so the
* flip animation replays per swap. */
flip: number;
}
export interface SplitFlapProps
extends Omit<React.ComponentProps<"span">, "children"> {
/** Text the board settles on. Changing it re-flips only the cells that differ. */
value: string;
/** Fixed cell count — pads with blank flaps (or trims) so width never shifts between values. */
length?: number;
/** Which edge the text sits against when `length` pads the board. */
align?: "left" | "right";
/** Milliseconds per flap step. */
flipMs?: number;
/** Milliseconds between neighboring cells starting to flip. House range 50–100. */
stagger?: number;
/** Maximum intermediate flaps per cell before it settles. */
maxFlips?: number;
/** Milliseconds before the first cell starts — useful for staggering boards. */
delay?: number;
/** Render the value immediately with no motion. */
static?: boolean;
}
/**
* Airport split-flap board: each character cell flips (3D rotateX) through
* intermediate characters before settling on its target, with a 50–100ms
* stagger across cells. Cells are fixed-width monospace tiles, so the layout
* never shifts mid-flip. Starts when scrolled into view; value changes
* mid-flight retarget from whatever is currently showing.
*
* The full string stays available to screen readers; the flapping cells are
* decorative. Reduced motion swaps characters straight to the target with a
* plain crossfade.
*/
export function SplitFlap({
value,
length,
align = "left",
flipMs = 70,
stagger = 60,
maxFlips = 7,
delay = 0,
static: isStatic = false,
className,
...props
}: SplitFlapProps) {
const ref = React.useRef<HTMLSpanElement>(null);
const reducedMotion = useReducedMotion() ?? false;
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const target = React.useMemo(() => {
let text = value;
if (length !== undefined) {
text = text.slice(0, length);
text =
align === "right"
? text.padStart(length, " ")
: text.padEnd(length, " ");
}
return text.split("");
}, [value, length, align]);
const [cells, setCells] = React.useState<Cell[]>(() =>
target.map(() => ({ char: " ", flip: 0 })),
);
// Mirror of what's on the board right now, readable synchronously inside
// the effect so a mid-flight value change retargets from the live state.
const displayRef = React.useRef<string[]>(target.map(() => " "));
const targetKey = target.join("");
React.useEffect(() => {
if (isStatic || !inView) return;
const current = target.map((_, i) => displayRef.current[i] ?? " ");
displayRef.current = current;
// Cell count changed (value grew or shrank) — resize without animating.
setCells((prev) =>
prev.length === target.length
? prev
: target.map((_, i) => prev[i] ?? { char: " ", flip: 0 }),
);
if (reducedMotion) {
// Plain crossfade: one swap straight to the target, no intermediates.
displayRef.current = target.slice();
setCells((prev) =>
target.map((char, i) => {
const cell = prev[i];
return cell && cell.char === char
? cell
: { char, flip: (cell?.flip ?? 0) + 1 };
}),
);
return;
}
const timers: number[] = [];
target.forEach((toChar, i) => {
const path = buildPath(current[i], toChar, maxFlips);
path.forEach((char, step) => {
timers.push(
window.setTimeout(
() => {
displayRef.current[i] = char;
setCells((prev) =>
prev.map((cell, j) =>
j === i ? { char, flip: cell.flip + 1 } : cell,
),
);
},
delay + i * stagger + step * flipMs,
),
);
});
});
return () => timers.forEach((timer) => window.clearTimeout(timer));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [targetKey, inView, reducedMotion, isStatic, flipMs, stagger, maxFlips, delay]);
const shown: Cell[] = isStatic
? target.map((char) => ({ char, flip: 0 }))
: cells;
return (
<span
ref={ref}
data-slot="split-flap"
data-split-flap=""
className={cn(
"inline-flex items-center font-mono tabular-nums",
className,
)}
{...props}
>
<style href="paragon-split-flap" precedence="paragon">{`
@keyframes pg-flap {
from { transform: rotateX(90deg); opacity: 0.25; }
to { transform: rotateX(0deg); opacity: 1; }
}
@keyframes pg-flap-fade {
from { opacity: 0; }
to { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
[data-split-flap] [data-flap] {
animation-name: pg-flap-fade !important;
}
}
`}</style>
<span className="sr-only">{value}</span>
<span aria-hidden="true" className="inline-flex items-center gap-[0.12em]">
{shown.map((cell, i) => (
<span
key={i}
className="relative inline-flex h-[1.45em] w-[1.35ch] shrink-0 items-center justify-center overflow-hidden rounded-[0.18em] bg-muted shadow-border [perspective:300px] before:pointer-events-none before:absolute before:inset-x-0 before:top-0 before:h-1/2 before:bg-foreground/[0.04] dark:bg-muted/60"
>
<span
key={cell.flip}
data-flap=""
className="leading-none [backface-visibility:hidden]"
style={
cell.flip > 0
? {
animation: `pg-flap ${flipMs}ms var(--ease-out)`,
}
: undefined
}
>
{cell.char}
</span>
<span className="pointer-events-none absolute inset-x-0 top-1/2 h-px bg-background/70" />
</span>
))}
</span>
</span>
);
}