Split-Flap Panel
codyshanley/split-flapFreeComponent
Solari-style split-flap display for React. CSS 3D, no canvas.
Install it
npx shadcn@latest add https://codyshanley.com/r/split-flap.jsonSource
1/**2 * Split-Flap Panel — a CSS-3D recreation of Solari-style departure boards.3 * Built by Cody Shanley · https://codyshanley.com/playground/split-flap?utm_source=github&utm_medium=attribution&utm_campaign=split-flap4 * MIT licensed. Free to use, modify, and redistribute.5 */6"use client";78import { useEffect, useRef, useState } from "react";910export const SPLITFLAP_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789. ";1112function nextChar(c: string): string {13 const i = SPLITFLAP_ALPHABET.indexOf(c);14 return SPLITFLAP_ALPHABET[(i + 1) % SPLITFLAP_ALPHABET.length];15}1617/** Forward-only distance from `from` to `to` on the one-way alphabet chain.18 * Matches real Solari mechanism: X -> D = 8 (via Y,Z,blank,A,B,C,D), not 6. */19export function computeFlipCount(from: string, to: string): number {20 const a = SPLITFLAP_ALPHABET.indexOf(from);21 const b = SPLITFLAP_ALPHABET.indexOf(to);22 if (a < 0 || b < 0) return 0;23 return (b - a + SPLITFLAP_ALPHABET.length) % SPLITFLAP_ALPHABET.length;24}2526interface Props {27 target: string;28 stepMs?: number;29 startDelayMs?: number;30 minFlips?: number;31 onFlip?: () => void;32 size?: "default" | "xl" | "compact" | "hero";33 /** Freeze the slat in a mid-flip pose for visual inspection. Disables animation. */34 frozenMidFlip?: boolean;35 /** Debug: override the letters shown. Useful for static reference slats. */36 staticLetter?: string;37 /** Letter to display as the initial state without animation. Read once at mount. */38 initialCurrent?: string;39}4041export function SplitFlapSlat({42 target,43 stepMs = 110,44 startDelayMs = 0,45 minFlips = 1,46 onFlip,47 size = "default",48 frozenMidFlip = false,49 staticLetter,50 initialCurrent,51}: Props) {52 const [current, setCurrent] = useState<string>(staticLetter ?? initialCurrent ?? " ");53 const [next, setNext] = useState<string | null>(null);54 const [isFlipping, setIsFlipping] = useState(false);55 const currentRef = useRef(current);5657 useEffect(() => {58 currentRef.current = current;59 }, [current]);6061 useEffect(() => {62 if (staticLetter !== undefined || frozenMidFlip) return;6364 const prefersReduced =65 typeof window !== "undefined" &&66 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;6768 if (prefersReduced) {69 setCurrent(target);70 return;71 }7273 let cancelled = false;74 const timers: ReturnType<typeof setTimeout>[] = [];7576 const flipsNeeded = () => {77 const from = SPLITFLAP_ALPHABET.indexOf(currentRef.current);78// … truncated
