{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "text-flipping-board",
  "type": "registry:block",
  "title": "Text flipping board",
  "description": "Text flipping board",
  "files": [
    {
      "path": "components/usages/textflippingboardusage.tsx",
      "content": "\"use client\";\nimport { TextFlippingBoard } from \"@/registry/open-source/text-flipping-board\";\nimport React, { useState, useEffect, useCallback } from \"react\";\n\nconst MESSAGES: string[] = [\n    \"STAY HUNGRY \\nSTAY IN BED \\n- STEVE JOBS\",\n    \"What did you get done this week?\",\n    \"I burned $20 \\nfor this shit.\",\n    \"DONT WORRY \\nBE HAPPY FFS.\",\n    \"LADIES AND GENTLEMEN \\nWELCOME TO F#!@# C!@$\",\n];\n\nexport function TextFlippingBoardDemo() {\n    const [msgIdx, setMsgIdx] = useState(0);\n\n    const next = useCallback(\n        () => setMsgIdx((i) => (i + 1) % MESSAGES.length),\n        [],\n    );\n\n    useEffect(() => {\n        const id = setInterval(next, 3000);\n        return () => clearInterval(id);\n    }, [next]);\n\n    return (\n        <div className=\"flex w-full flex-col items-center justify-center gap-8 py-20\">\n            <TextFlippingBoard text={MESSAGES[msgIdx]} />\n        </div>\n    );\n}\n",
      "type": "registry:block",
      "target": "~/example.tsx"
    },
    {
      "path": "components/usages/textflippingboardusage.tsx",
      "content": "\"use client\";\nimport { TextFlippingBoard } from \"@/registry/open-source/text-flipping-board\";\nimport React, { useState, useEffect, useCallback } from \"react\";\n\nconst MESSAGES: string[] = [\n    \"STAY HUNGRY \\nSTAY IN BED \\n- STEVE JOBS\",\n    \"What did you get done this week?\",\n    \"I burned $20 \\nfor this shit.\",\n    \"DONT WORRY \\nBE HAPPY FFS.\",\n    \"LADIES AND GENTLEMEN \\nWELCOME TO F#!@# C!@$\",\n];\n\nexport function TextFlippingBoardDemo() {\n    const [msgIdx, setMsgIdx] = useState(0);\n\n    const next = useCallback(\n        () => setMsgIdx((i) => (i + 1) % MESSAGES.length),\n        [],\n    );\n\n    useEffect(() => {\n        const id = setInterval(next, 3000);\n        return () => clearInterval(id);\n    }, [next]);\n\n    return (\n        <div className=\"flex w-full flex-col items-center justify-center gap-8 py-20\">\n            <TextFlippingBoard text={MESSAGES[msgIdx]} />\n        </div>\n    );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/open-source/text-flipping-board.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useRef, useState, useMemo } from \"react\";\nimport { motion } from \"motion/react\";\nimport { cn } from \"@/registry/utilities/cn\";\n\nconst FLAP_CHARS = \" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$()-+&=;:'\\\"%,./?°\";\n\nconst BOARD_ROWS = 6;\nconst BOARD_COLS = 22;\n\nconst BASE_COL_DELAY = 30;\nconst BASE_ROW_DELAY = 20;\nconst BASE_STEP_MS = 55;\nconst BASE_FLIP_S = 0.35;\nconst BASE_TOTAL_S =\n    ((BOARD_COLS - 1) * BASE_COL_DELAY +\n        (BOARD_ROWS - 1) * BASE_ROW_DELAY +\n        8 * BASE_STEP_MS) /\n    1000;\n\ntype AccentColor = {\n    top: string;\n    bottom: string;\n    text: string;\n};\n\nconst ACCENT_COLORS: AccentColor[] = [\n    { top: \"bg-red-600\", bottom: \"bg-red-700\", text: \"text-white\" },\n    { top: \"bg-orange-500\", bottom: \"bg-orange-600\", text: \"text-white\" },\n    { top: \"bg-yellow-400\", bottom: \"bg-yellow-500\", text: \"text-neutral-900\" },\n    { top: \"bg-green-600\", bottom: \"bg-green-700\", text: \"text-white\" },\n    { top: \"bg-blue-600\", bottom: \"bg-blue-700\", text: \"text-white\" },\n    { top: \"bg-violet-600\", bottom: \"bg-violet-700\", text: \"text-white\" },\n    { top: \"bg-white\", bottom: \"bg-neutral-100\", text: \"text-neutral-900\" },\n];\n\nconst CELL_TEXT_STYLE: React.CSSProperties = {\n    fontSize: \"clamp(6px, 2vw, 22px)\",\n    lineHeight: 1,\n};\n\n// ── Individual Split-Flap Character ───────────────────────────────────\n\nconst FlapCell = React.memo(function FlapCell({\n    target,\n    delay,\n    stepMs,\n    flipDuration,\n}: {\n    target: string;\n    delay: number;\n    stepMs: number;\n    flipDuration: number;\n}) {\n    const [current, setCurrent] = useState(\" \");\n    const [prev, setPrev] = useState(\" \");\n    const [flipId, setFlipId] = useState(0);\n    const [accent, setAccent] = useState<AccentColor | null>(null);\n    const [prevAccent, setPrevAccent] = useState<AccentColor | null>(null);\n    const curRef = useRef(\" \");\n    const tgtRef = useRef<string | null>(null);\n    const accentRef = useRef<AccentColor | null>(null);\n    const startTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n    const stepTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n    useEffect(() => {\n        if (startTimer.current) clearTimeout(startTimer.current);\n        if (stepTimer.current) clearTimeout(stepTimer.current);\n        startTimer.current = null;\n        stepTimer.current = null;\n\n        const normalized = FLAP_CHARS.includes(target.toUpperCase())\n            ? target.toUpperCase()\n            : \" \";\n        if (normalized === tgtRef.current) return;\n        tgtRef.current = normalized;\n\n        if (normalized === \" \" && curRef.current === \" \") return;\n\n        const scrambleCount =\n            normalized === \" \"\n                ? 2 + Math.floor(Math.random() * 3)\n                : 5 + Math.floor(Math.random() * 7);\n\n        const runStep = (i: number) => {\n            const isLast = i === scrambleCount;\n            const ch = isLast\n                ? normalized\n                : FLAP_CHARS[1 + Math.floor(Math.random() * (FLAP_CHARS.length - 1))];\n\n            const newAccent = isLast\n                ? null\n                : Math.random() < 0.2\n                    ? ACCENT_COLORS[Math.floor(Math.random() * ACCENT_COLORS.length)]\n                    : null;\n\n            setPrev(curRef.current);\n            setPrevAccent(accentRef.current);\n            curRef.current = ch;\n            accentRef.current = newAccent;\n            setCurrent(ch);\n            setAccent(newAccent);\n            setFlipId((n) => n + 1);\n\n            if (!isLast) {\n                stepTimer.current = setTimeout(() => runStep(i + 1), stepMs);\n            }\n        };\n\n        startTimer.current = setTimeout(() => runStep(1), delay);\n\n        return () => {\n            if (startTimer.current) clearTimeout(startTimer.current);\n            if (stepTimer.current) clearTimeout(stepTimer.current);\n            startTimer.current = null;\n            stepTimer.current = null;\n            tgtRef.current = null;\n        };\n    }, [target, delay, stepMs]);\n\n    const show = current === \" \" ? \"\\u00A0\" : current;\n    const showPrev = prev === \" \" ? \"\\u00A0\" : prev;\n\n    const textCx =\n        \"absolute inset-x-0 flex select-none items-center justify-center font-mono font-bold tracking-wide\";\n    const topBg = accent?.top ?? \"bg-neutral-200/80 dark:bg-neutral-900\";\n    const bottomBg = accent?.bottom ?? \"bg-neutral-200/80 dark:bg-neutral-900\";\n    const textColor = accent?.text ?? \"text-neutral-800 dark:text-white\";\n\n    const flapTopBg = prevAccent?.top ?? \"bg-neutral-100 dark:bg-neutral-800\";\n    const flapTextColor = prevAccent?.text ?? \"text-neutral-800 dark:text-white\";\n\n    const bottomDelay = flipDuration * 0.5;\n\n    return (\n        <div className=\"flex aspect-3/6 flex-col overflow-hidden rounded-[2px] border border-neutral-300 md:rounded-[3px] md:border-2 dark:border-black\">\n            {/* Flap content area */}\n            <div className=\"relative flex-1 perspective-dramatic transform-3d\">\n                <div className=\"absolute inset-0 z-40 hidden flex-row items-center justify-center md:flex\">\n                    <div className=\"h-1/2 w-px rounded-tr-sm rounded-br-sm bg-neutral-300 dark:bg-black\" />\n                    <div className=\"flex h-px flex-1 bg-neutral-300 dark:bg-black\" />\n                    <div className=\"h-1/2 w-px rounded-tl-sm rounded-bl-sm bg-neutral-300 dark:bg-black\" />\n                </div>\n\n                {/* Static top – new character top half */}\n                <div\n                    className={cn(\n                        \"absolute inset-x-0 top-0 h-[calc(50%-0.5px)] overflow-hidden rounded-t-[3px]\",\n                        topBg,\n                    )}\n                >\n                    <div\n                        className={cn(textCx, textColor, \"top-0 h-[200%]\")}\n                        style={CELL_TEXT_STYLE}\n                    >\n                        {show}\n                    </div>\n                </div>\n\n                {/* Static bottom – new character bottom half */}\n                <div\n                    className={cn(\n                        \"absolute inset-x-0 bottom-0 h-[calc(50%-0.5px)] overflow-hidden rounded-b-[3px]\",\n                        bottomBg,\n                    )}\n                >\n                    <div\n                        className={cn(textCx, textColor, \"bottom-0 h-[200%]\")}\n                        style={CELL_TEXT_STYLE}\n                    >\n                        {show}\n                    </div>\n                    {flipId > 0 && (\n                        <motion.div\n                            key={`s${flipId}`}\n                            className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0.8),transparent_60%)] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0.8),transparent_60%)]\"\n                            initial={{ opacity: 0.5 }}\n                            animate={{ opacity: 0 }}\n                            transition={{ duration: flipDuration * 1.3, ease: \"easeOut\" }}\n                        />\n                    )}\n                </div>\n\n                {/* Flipping top flap – old character top half, drops down */}\n                {flipId > 0 && (\n                    <motion.div\n                        key={flipId}\n                        className={cn(\n                            \"absolute inset-x-0 top-0 z-10 h-[calc(50%-0.5px)] origin-bottom overflow-hidden rounded-t-[3px] backface-hidden transform-3d\",\n                            flapTopBg,\n                        )}\n                        initial={{ rotateX: 0 }}\n                        animate={{ rotateX: -100 }}\n                        transition={{\n                            duration: flipDuration,\n                            ease: [0.55, 0.055, 0.675, 0.19],\n                        }}\n                    >\n                        <div\n                            className={cn(textCx, flapTextColor, \"top-0 h-[200%]\")}\n                            style={CELL_TEXT_STYLE}\n                        >\n                            {showPrev}\n                        </div>\n                        <motion.div\n                            className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_bottom,rgba(255,255,255,0),rgba(255,255,255,1))] dark:bg-[linear-gradient(to_bottom,rgba(0,0,0,0),rgba(0,0,0,1))]\"\n                            initial={{ opacity: 0 }}\n                            animate={{ opacity: 0.6 }}\n                            transition={{ duration: flipDuration }}\n                        />\n                    </motion.div>\n                )}\n\n                {/* Flipping bottom flap – new character bottom half, rises up */}\n                {flipId > 0 && (\n                    <motion.div\n                        key={`b${flipId}`}\n                        className={cn(\n                            \"absolute inset-x-0 bottom-0 z-10 h-[calc(50%-0.5px)] origin-top overflow-hidden rounded-b-[3px] backface-hidden transform-3d\",\n                            bottomBg,\n                        )}\n                        initial={{ rotateX: 90 }}\n                        animate={{ rotateX: 0 }}\n                        transition={{\n                            duration: flipDuration * 0.85,\n                            delay: bottomDelay,\n                            ease: [0.33, 1.55, 0.64, 1],\n                        }}\n                    >\n                        <div\n                            className={cn(textCx, textColor, \"bottom-0 h-[200%]\")}\n                            style={CELL_TEXT_STYLE}\n                        >\n                            {show}\n                        </div>\n                        <motion.div\n                            className=\"pointer-events-none absolute inset-0 bg-[linear-gradient(to_top,rgba(255,255,255,0),rgba(255,255,255,0.6))] dark:bg-[linear-gradient(to_top,rgba(0,0,0,0),rgba(0,0,0,0.6))]\"\n                            initial={{ opacity: 0.4 }}\n                            animate={{ opacity: 0 }}\n                            transition={{\n                                duration: flipDuration * 0.85,\n                                delay: bottomDelay,\n                            }}\n                        />\n                    </motion.div>\n                )}\n\n                {/* Split line */}\n                <div className=\"pointer-events-none absolute inset-x-0 top-1/2 z-20 h-px -translate-y-[0.5px] bg-neutral-400/50 dark:bg-black/50\" />\n            </div>\n\n            {/* Bottom stripes – decorative, outside the flap area */}\n            <div className=\"h-2 w-full bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.15rem)] mask-t-from-50% text-neutral-400 opacity-20 md:h-4 md:bg-[repeating-linear-gradient(to_bottom,currentColor_0,currentColor_1px,transparent_1px,transparent_0.2rem)] dark:text-black dark:opacity-100\" />\n        </div>\n    );\n},\n    (prevProps, nextProps) =>\n        prevProps.target === nextProps.target &&\n        prevProps.delay === nextProps.delay &&\n        prevProps.stepMs === nextProps.stepMs &&\n        prevProps.flipDuration === nextProps.flipDuration,\n);\n\n// ── Color Tile ────────────────────────────────────────────────────────\n\nconst COLOR_MAP: Record<string, string> = {\n    \"{R}\": \"#D32F2F\",\n    \"{O}\": \"#F57C00\",\n    \"{Y}\": \"#FBC02D\",\n    \"{G}\": \"#43A047\",\n    \"{B}\": \"#1E88E5\",\n    \"{V}\": \"#8E24AA\",\n    \"{W}\": \"#FAFAFA\",\n};\n\nconst ColorCell = React.memo(function ColorCell({ color }: { color: string }) {\n    return (\n        <div\n            className=\"aspect-3/5 rounded-[3px] border-2 border-neutral-300 dark:border-black\"\n            style={{ backgroundColor: color }}\n        />\n    );\n});\n\n// ── Row Parser ────────────────────────────────────────────────────────\n\ntype ParsedCell =\n    | { type: \"char\"; value: string }\n    | { type: \"color\"; hex: string };\n\nfunction parseRow(row: string): ParsedCell[] {\n    const cells: ParsedCell[] = [];\n    let i = 0;\n    while (i < row.length) {\n        if (row[i] === \"{\" && i + 2 < row.length && row[i + 2] === \"}\") {\n            const code = row.substring(i, i + 3);\n            if (COLOR_MAP[code]) {\n                cells.push({ type: \"color\", hex: COLOR_MAP[code] });\n                i += 3;\n                continue;\n            }\n        }\n        cells.push({ type: \"char\", value: row[i] });\n        i++;\n    }\n    return cells;\n}\n\n// ── Word Wrap ─────────────────────────────────────────────────────────\n\nfunction wrapParagraph(paragraph: string, maxCols: number): string[] {\n    const lines: string[] = [];\n    const words = paragraph.split(/[ \\t]+/).filter(Boolean);\n    let currentLine = \"\";\n\n    for (const word of words) {\n        if (word.length > maxCols) {\n            if (currentLine) {\n                lines.push(currentLine);\n                currentLine = \"\";\n            }\n            lines.push(word.slice(0, maxCols));\n            continue;\n        }\n\n        if (!currentLine) {\n            currentLine = word;\n        } else if (currentLine.length + 1 + word.length <= maxCols) {\n            currentLine += \" \" + word;\n        } else {\n            lines.push(currentLine);\n            currentLine = word;\n        }\n    }\n\n    if (currentLine) lines.push(currentLine);\n    return lines;\n}\n\nfunction wrapText(input: string, maxCols: number): string[] {\n    return input\n        .split(\"\\n\")\n        .flatMap((paragraph) =>\n            paragraph.trim() === \"\" ? [\"\"] : wrapParagraph(paragraph, maxCols),\n        );\n}\n\n// ── Main TextFlippingBoard Component ──────────────────────────────────\n\nexport interface TextFlippingBoardProps {\n    rows?: string[];\n    text?: string;\n    className?: string;\n    /** Total animation duration in seconds. Defaults to ~1.2s. */\n    duration?: number;\n}\n\nexport function TextFlippingBoard({\n    rows,\n    text,\n    className,\n    duration = BASE_TOTAL_S,\n}: TextFlippingBoardProps) {\n    const scale = duration / BASE_TOTAL_S;\n    const colDelay = BASE_COL_DELAY * scale;\n    const rowDelay = BASE_ROW_DELAY * scale;\n    const stepMs = BASE_STEP_MS * scale;\n    const flipDur = Math.min(0.6, Math.max(0.15, BASE_FLIP_S * scale));\n\n    const board = useMemo(() => {\n        const grid: ParsedCell[][] = Array.from({ length: BOARD_ROWS }, () =>\n            Array.from({ length: BOARD_COLS }, () => ({\n                type: \"char\" as const,\n                value: \" \",\n            })),\n        );\n\n        if (text) {\n            const lines = wrapText(text, BOARD_COLS).slice(0, BOARD_ROWS);\n            const startRow = Math.max(0, Math.floor((BOARD_ROWS - lines.length) / 2));\n            lines.forEach((line, i) => {\n                const row = startRow + i;\n                if (row >= BOARD_ROWS) return;\n                const parsed = parseRow(line);\n                const startCol = Math.max(\n                    0,\n                    Math.floor((BOARD_COLS - parsed.length) / 2),\n                );\n                parsed.forEach((cell, c) => {\n                    if (startCol + c < BOARD_COLS) {\n                        grid[row][startCol + c] = cell;\n                    }\n                });\n            });\n        } else if (rows) {\n            rows.forEach((row, r) => {\n                if (r >= BOARD_ROWS) return;\n                const parsed = parseRow(row);\n                parsed.forEach((cell, c) => {\n                    if (c < BOARD_COLS) {\n                        grid[r][c] = cell;\n                    }\n                });\n            });\n        }\n\n        return grid;\n    }, [rows, text]);\n\n    return (\n        <div\n            className={cn(\n                \"relative mx-auto w-full max-w-3xl rounded-xl bg-neutral-100 p-2 shadow-xl md:rounded-2xl md:p-4 dark:bg-neutral-900 dark:shadow-[0_20px_70px_-15px_rgba(0,0,0,0.6)]\",\n                className,\n            )}\n        >\n            <div\n                className=\"grid gap-px md:gap-[3px]\"\n                style={{ gridTemplateColumns: `repeat(${BOARD_COLS}, 1fr)` }}\n            >\n                {board.map((row, r) =>\n                    row.map((cell, c) =>\n                        cell.type === \"color\" ? (\n                            <ColorCell key={`${r}-${c}`} color={cell.hex} />\n                        ) : (\n                            <FlapCell\n                                key={`${r}-${c}`}\n                                target={cell.value}\n                                delay={c * colDelay + r * rowDelay}\n                                stepMs={stepMs}\n                                flipDuration={flipDur}\n                            />\n                        ),\n                    ),\n                )}\n            </div>\n        </div>\n    );\n}\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/utilities/cn.ts",
      "content": "import { ClassValue, clsx } from \"clsx\";\r\nimport { twMerge } from \"tailwind-merge\";\r\n\r\nexport function cn(...inputs: ClassValue[]) {\r\n\treturn twMerge(clsx(inputs));\r\n}\r\n",
      "type": "registry:ui"
    }
  ]
}