{
  "name": "text-flipping-board",
  "type": "registry:ui",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/text-flipping-board.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useRef, useState, useMemo } from \"react\";\nimport { motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\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        ? 8 + Math.floor(Math.random() * 8)\n        : 25 + Math.floor(Math.random() * 15);\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",
      "target": "components/ui/text-flipping-board.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Text Flipping Board"
}