"use client";
import * as React from "react";
export type SplitFlapTheme = "dark" | "light" | "auto";
export type SplitFlapSize = "sm" | "md" | "lg";
export interface SplitFlapProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "children"> {
/** Text to show. Changing it flips every cell from its current character to the new one. */
value?: string;
/** Minimum number of cells; shorter values are padded with blanks. */
length?: number;
/** Which side short values are aligned to inside `length`. */
align?: "left" | "right";
/** Ordered drum of characters each cell steps through. Characters outside it flip directly. */
charset?: string;
/** Duration of a single flap flip, in ms. */
flipDuration?: number;
/** Delay between neighbouring cells starting, in ms. */
stagger?: number;
/** Cell size. */
size?: SplitFlapSize;
/** Tile colours: classic dark tiles, light tiles, or follow the page theme. */
theme?: SplitFlapTheme;
/** Upper-case the value (classic boards have no lower-case flaps). */
uppercase?: boolean;
}
/* --------------------------- reduced-motion hook --------------------------- */
const REDUCED_QUERY = "(prefers-reduced-motion: reduce)";
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY);
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia(REDUCED_QUERY).matches,
() => false,
);
}
/* --------------------------------- styling -------------------------------- */
const SIZES: Record<SplitFlapSize, string> = {
sm: "text-[18px]",
md: "text-[28px]",
lg: "text-[44px]",
};
interface ThemeClasses {
top: string;
bottom: string;
text: string;
frame: string;
hinge: string;
}
const THEMES: Record<SplitFlapTheme, ThemeClasses> = {
dark: {
top: "bg-[#24272b]",
bottom: "bg-[#1b1d20]",
text: "text-[#F3EFE6]",
frame: "shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_0_0_1px_rgba(0,0,0,0.55),0_2px_6px_-2px_rgba(0,0,0,0.5)]",
hinge: "bg-black/70",
},
light: {
top: "bg-white",
bottom: "bg-[#f6f5f1]",
text: "text-zinc-900",
frame: "shadow-[0_0_0_1px_rgba(24,24,27,0.12),0_2px_5px_-3px_rgba(24,24,27,0.35)]",
hinge: "bg-zinc-900/15",
},
auto: {
top: "bg-white dark:bg-[#24272b]",
bottom: "bg-[#f6f5f1] dark:bg-[#1b1d20]",
text: "text-zinc-900 dark:text-[#F3EFE6]",
frame:
"shadow-[0_0_0_1px_rgba(24,24,27,0.12),0_2px_5px_-3px_rgba(24,24,27,0.35)] dark:shadow-[0_1px_0_rgba(255,255,255,0.06)_inset,0_0_0_1px_rgba(0,0,0,0.55),0_2px_6px_-2px_rgba(0,0,0,0.5)]",
hinge: "bg-zinc-900/15 dark:bg-black/70",
},
};
/* ---------------------------------- model --------------------------------- */
const DEFAULT_CHARSET = " ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/";
function toCells(value: string, length: number, align: "left" | "right", uppercase: boolean) {
const chars = Array.from(uppercase ? value.toUpperCase() : value).map((c) => (/\s/.test(c) ? " " : c));
const count = Math.max(chars.length, Math.max(0, Math.round(length)));
const pad = Array.from({ length: count - chars.length }, () => " ");
return align === "right" ? [...pad, ...chars] : [...chars, ...pad];
}
/** Characters a cell shows on its way from `from` to `to`, stepping forward round the drum. */
function flipSequence(from: string, to: string, drum: string[]) {
if (from === to) return [];
const a = drum.indexOf(from);
const b = drum.indexOf(to);
if (a < 0 || b < 0) return [to];
const steps: string[] = [];
for (let i = (a + 1) % drum.length; ; i = (i + 1) % drum.length) {
steps.push(drum[i]);
if (i === b) break;
}
return steps;
}
interface CellState {
/** Character fully shown. */
current: string;
/** Character the flap is currently falling towards. */
next: string | null;
/** Elapsed time of the running flip, ms. */
elapsed: number;
/** Time left before this cell starts, ms. */
delay: number;
queue: string[];
}
interface CellNodes {
top: HTMLElement;
bottom: HTMLElement;
leafTop: HTMLElement;
leafBottom: HTMLElement;
leafTopGlyph: HTMLElement;
leafBottomGlyph: HTMLElement;
}
function queryCell(cell: HTMLElement): CellNodes | null {
const get = (name: string) => cell.querySelector<HTMLElement>(`[data-sf="${name}"]`);
const nodes = {
top: get("top"),
bottom: get("bottom"),
leafTop: get("leaf-top"),
leafBottom: get("leaf-bottom"),
leafTopGlyph: get("leaf-top-glyph"),
leafBottomGlyph: get("leaf-bottom-glyph"),
};
return Object.values(nodes).every(Boolean) ? (nodes as CellNodes) : null;
}
const easeIn = (t: number) => t * t;
const easeOutBack = (t: number) => {
// A small overshoot reads as the flap slapping onto the stack.
const c = 1.4;
return 1 + (c + 1) * Math.pow(t - 1, 3) + c * Math.pow(t - 1, 2);
};
function setLeaves(nodes: CellNodes, progress: number) {
if (progress < 0.5) {
const a = easeIn(progress / 0.5);
nodes.leafTop.style.transform = `rotateX(${-90 * a}deg)`;
nodes.leafTop.style.setProperty("--sf-shade", String(0.45 * a));
nodes.leafBottom.style.transform = "rotateX(90deg)";
} else {
const q = easeOutBack((progress - 0.5) / 0.5);
nodes.leafTop.style.transform = "rotateX(-90deg)";
nodes.leafBottom.style.transform = `rotateX(${90 * (1 - q)}deg)`;
nodes.leafBottom.style.setProperty("--sf-shade", String(Math.max(0, 0.45 * (1 - q))));
}
}
function showLeaves(nodes: CellNodes, visible: boolean) {
const v = visible ? "visible" : "hidden";
nodes.leafTop.style.visibility = v;
nodes.leafBottom.style.visibility = v;
}
/* -------------------------------- component -------------------------------- */
/**
* A Solari-style departure board. Each character is a tile with a hinged top flap: on a change
* the flap falls (rotateX) to reveal the next character on the drum, cell after cell, until the
* target text is spelled out. Flips are driven by one requestAnimationFrame loop writing styles
* straight to the DOM, so React does not re-render per frame.
*/
export function SplitFlap({
value = "Kuching 0915",
length = 12,
align = "left",
charset = DEFAULT_CHARSET,
flipDuration = 80,
stagger = 30,
size = "md",
theme = "dark",
uppercase = true,
className = "",
...props
}: SplitFlapProps) {
const reduceMotion = usePrefersReducedMotion();
const boardRef = React.useRef<HTMLDivElement>(null);
const statesRef = React.useRef<CellState[]>([]);
const cells = toCells(value, length, align, uppercase);
const target = cells.join("");
// Cells render their first character once; afterwards the flip engine owns their text.
const [initialCells] = React.useState(cells);
React.useEffect(() => {
const board = boardRef.current;
if (!board) return;
const cellEls = Array.from(board.querySelectorAll<HTMLElement>("[data-sf-cell]"));
const nodes = cellEls.map(queryCell);
const states = statesRef.current;
states.length = Math.min(states.length, cellEls.length);
cellEls.forEach((el, i) => {
states[i] ??= { current: el.dataset.char ?? " ", next: null, elapsed: 0, delay: 0, queue: [] };
});
const targetCells = Array.from(target);
const drum = Array.from(new Set(Array.from(charset)));
if (reduceMotion || flipDuration <= 0) {
// Instant swap: no flaps, no stepping through the drum.
states.forEach((state, i) => {
const n = nodes[i];
const ch = targetCells[i] ?? " ";
Object.assign(state, { current: ch, next: null, elapsed: 0, delay: 0, queue: [] });
if (!n) return;
n.top.textContent = ch;
n.bottom.textContent = ch;
showLeaves(n, false);
});
return;
}
let started = 0;
states.forEach((state, i) => {
const from = state.next ?? state.current;
state.queue = flipSequence(from, targetCells[i] ?? " ", drum);
// Cells that are mid-flip keep going; idle cells wait for their turn in the stagger.
if (state.next === null && state.queue.length) state.delay = started++ * stagger;
});
const duration = Math.max(16, flipDuration);
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
// Cap the step so a backgrounded tab resumes mid-animation instead of jumping.
const dt = Math.min(64, now - last);
last = now;
let active = false;
states.forEach((state, i) => {
const n = nodes[i];
if (!n) return;
if (state.next === null) {
if (!state.queue.length) return;
active = true;
if (state.delay > 0) {
state.delay -= dt;
return;
}
state.next = state.queue.shift() ?? " ";
state.elapsed = 0;
// Behind the falling flap: next character on top, current character on the bottom.
n.top.textContent = state.next;
n.leafTopGlyph.textContent = state.current;
n.leafBottomGlyph.textContent = state.next;
showLeaves(n, true);
}
active = true;
state.elapsed += dt;
const p = Math.min(1, state.elapsed / duration);
setLeaves(n, p);
if (p >= 1) {
state.current = state.next;
state.next = null;
n.bottom.textContent = state.current;
showLeaves(n, false);
}
});
frame = active ? requestAnimationFrame(tick) : 0;
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [target, charset, flipDuration, stagger, reduceMotion]);
const t = THEMES[theme] ?? THEMES.dark;
const half = `absolute inset-x-0 h-1/2 overflow-hidden ${t.text}`;
const glyph = "absolute inset-x-0 flex h-[200%] items-center justify-center leading-none";
return (
<div className={`inline-block ${SIZES[size] ?? SIZES.md} ${className}`} {...props}>
<span className="sr-only" aria-live="polite" aria-atomic="true">
{value.trim()}
</span>
<div ref={boardRef} aria-hidden="true" className="flex flex-wrap gap-[0.09em] font-semibold tracking-normal select-none">
{cells.map((_, i) => {
const ch = initialCells[i] ?? " ";
return (
<span
key={i}
data-sf-cell=""
data-char={ch}
className={`relative block h-[1.36em] w-[0.92em] rounded-[0.1em] [perspective:4em] ${t.frame}`}
>
{/* Static halves: top shows the incoming character, bottom the outgoing one. */}
<span className={`${half} top-0 rounded-t-[0.1em] ${t.top}`}>
<span data-sf="top" className={`${glyph} top-0`}>
{ch}
</span>
</span>
<span className={`${half} bottom-0 rounded-b-[0.1em] ${t.bottom}`}>
<span data-sf="bottom" className={`${glyph} bottom-0`}>
{ch}
</span>
</span>
{/* Moving flaps, hidden while idle. */}
<span
data-sf="leaf-top"
className={`${half} invisible top-0 origin-bottom rounded-t-[0.1em] [backface-visibility:hidden] ${t.top}`}
>
<span data-sf="leaf-top-glyph" className={`${glyph} top-0`} />
<span className="absolute inset-0 bg-black opacity-[var(--sf-shade,0)]" />
</span>
<span
data-sf="leaf-bottom"
className={`${half} invisible bottom-0 origin-top rounded-b-[0.1em] [backface-visibility:hidden] [transform:rotateX(90deg)] ${t.bottom}`}
>
<span data-sf="leaf-bottom-glyph" className={`${glyph} bottom-0`} />
<span className="absolute inset-0 bg-black opacity-[var(--sf-shade,0)]" />
</span>
{/* Hinge line between the flaps. */}
<span className={`absolute inset-x-0 top-1/2 h-px -translate-y-1/2 ${t.hinge}`} />
</span>
);
})}
</div>
</div>
);
}