{
  "name": "split-flap-text",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Split Flap Text\n * @version: 1.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport {\n  type CSSProperties,\n  type Ref,\n  useEffect,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\nimport { cn } from \"@/lib/utils\";\n\n/** The order the flaps are printed in. A tile can only flip forward. */\nconst CHARSET = \" ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:.-/&!?\";\n/** One flap falling, in ms. Quick, so long trips round the drum still feel brisk. */\nconst FLIP = 56;\n/** Tiles start a beat apart, left to right. */\nconst STAGGER = 40;\n\nexport interface SplitFlapTextProps {\n  /** Show this text. Leave out to cycle through `words`. */\n  text?: string;\n  /** Lines to cycle through when `text` isn't set. */\n  words?: string[];\n  /** Time each word stays up, in ms. */\n  interval?: number;\n  /** Tiles always shown, so the board doesn't change width. Defaults to the longest word. */\n  length?: number;\n  className?: string;\n  /** Classes for each tile, to recolor the board. */\n  tileClassName?: string;\n}\n\nconst clean = (value: string) =>\n  [...value.toUpperCase()].map((c) => (CHARSET.includes(c) ? c : \" \")).join(\"\");\n\nfunction Half({\n  char,\n  half,\n  className,\n  ref,\n  style,\n}: {\n  char: string;\n  half: \"top\" | \"bottom\";\n  className?: string;\n  ref?: Ref<HTMLSpanElement>;\n  style?: CSSProperties;\n}) {\n  return (\n    <span\n      className={cn(\n        \"backface-hidden absolute inset-x-0 h-1/2 overflow-hidden bg-inherit\",\n        half === \"top\"\n          ? \"top-0 rounded-t-[0.12em]\"\n          : \"bottom-0 rounded-b-[0.12em]\",\n        className\n      )}\n      ref={ref}\n      style={style}\n    >\n      <span\n        className={cn(\n          \"absolute inset-x-0 flex h-[200%] items-center justify-center\",\n          half === \"top\" ? \"top-0\" : \"bottom-0\"\n        )}\n      >\n        {char}\n      </span>\n    </span>\n  );\n}\n\ninterface Flip {\n  id: number;\n  from: string;\n  to: string;\n}\n\nfunction Tile({\n  target,\n  delay,\n  reduceMotion,\n  className,\n}: {\n  target: string;\n  delay: number;\n  reduceMotion: boolean;\n  className?: string;\n}) {\n  const [shown, setShown] = useState(target);\n  const [flip, setFlip] = useState<Flip | null>(null);\n  const shownRef = useRef(target);\n  const topRef = useRef<HTMLSpanElement>(null);\n  const bottomRef = useRef<HTMLSpanElement>(null);\n  const shadeRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    if (reduceMotion) {\n      shownRef.current = target;\n      setShown(target);\n      return;\n    }\n    let timer: ReturnType<typeof setTimeout>;\n    let id = 0;\n    // Step through the drum one flap at a time until the target is up.\n    const step = () => {\n      const from = shownRef.current;\n      if (from === target) {\n        setFlip(null);\n        return;\n      }\n      const to = CHARSET[(CHARSET.indexOf(from) + 1) % CHARSET.length];\n      id += 1;\n      setFlip({ id, from, to });\n      timer = setTimeout(() => {\n        shownRef.current = to;\n        setShown(to);\n        step();\n      }, FLIP);\n    };\n    timer = setTimeout(step, delay);\n    return () => clearTimeout(timer);\n  }, [target, delay, reduceMotion]);\n\n  // The top flap falls on its hinge, the bottom of the next letter lands,\n  // and the lower half darkens for a moment under the falling flap.\n  useLayoutEffect(() => {\n    if (!flip) {\n      return;\n    }\n    const half = FLIP / 2;\n    topRef.current?.animate(\n      [{ transform: \"rotateX(0deg)\" }, { transform: \"rotateX(-90deg)\" }],\n      {\n        duration: half,\n        easing: \"cubic-bezier(0.5, 0, 0.9, 0.6)\",\n        fill: \"forwards\",\n      }\n    );\n    bottomRef.current?.animate(\n      [{ transform: \"rotateX(90deg)\" }, { transform: \"rotateX(0deg)\" }],\n      {\n        duration: half,\n        delay: half,\n        easing: \"cubic-bezier(0.2, 0.6, 0.3, 1)\",\n        fill: \"both\",\n      }\n    );\n    shadeRef.current?.animate(\n      [{ opacity: 0 }, { opacity: 1 }, { opacity: 0 }],\n      {\n        duration: FLIP,\n        easing: \"linear\",\n      }\n    );\n  }, [flip]);\n\n  const front = flip ? flip.to : shown;\n  const back = flip ? flip.from : shown;\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className={cn(\n        \"relative inline-block h-[1.15em] w-[0.72em] rounded-[0.12em] bg-zinc-900 text-zinc-50 shadow-[inset_0_0_0_1px_rgb(255_255_255/0.06)] [perspective:5em]\",\n        className\n      )}\n    >\n      {/* Still halves: the next letter's top, the old letter's bottom. */}\n      <Half char={front} half=\"top\" />\n      <Half char={back} half=\"bottom\" />\n      <span\n        className=\"pointer-events-none absolute inset-x-0 bottom-0 h-1/2 rounded-b-[0.12em] bg-black/50 opacity-0\"\n        ref={shadeRef}\n      />\n      {flip && (\n        <>\n          <Half\n            char={flip.from}\n            half=\"top\"\n            key={`t-${flip.id}`}\n            ref={topRef}\n            style={{ transformOrigin: \"50% 100%\" }}\n          />\n          <Half\n            char={flip.to}\n            half=\"bottom\"\n            key={`b-${flip.id}`}\n            ref={bottomRef}\n            style={{ transformOrigin: \"50% 0%\", transform: \"rotateX(90deg)\" }}\n          />\n        </>\n      )}\n      {/* The hinge. */}\n      <span className=\"pointer-events-none absolute inset-x-0 top-1/2 h-[0.04em] -translate-y-1/2 bg-black/70\" />\n    </span>\n  );\n}\n\n/**\n * Departure-board lettering. Each tile flips through its drum only as far\n * as its new letter, the tiles start a beat apart, and the flaps fall on a\n * real hinge with the half below darkening under them.\n */\nexport default function SplitFlapText({\n  text,\n  words = [\"NOW BOARDING\", \"GATE 12\", \"ON TIME\", \"LAST CALL\"],\n  interval = 3800,\n  length,\n  className,\n  tileClassName,\n}: SplitFlapTextProps) {\n  const [index, setIndex] = useState(0);\n  const [reduceMotion, setReduceMotion] = useState(false);\n\n  useEffect(() => {\n    const query = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    setReduceMotion(query.matches);\n    const onChange = () => setReduceMotion(query.matches);\n    query.addEventListener(\"change\", onChange);\n    return () => query.removeEventListener(\"change\", onChange);\n  }, []);\n\n  useEffect(() => {\n    if (text !== undefined || words.length < 2) {\n      return;\n    }\n    const id = window.setInterval(\n      () => setIndex((i) => (i + 1) % words.length),\n      interval\n    );\n    return () => window.clearInterval(id);\n  }, [text, words.length, interval]);\n\n  const value = clean(text ?? words[index] ?? \"\");\n  const size =\n    length ??\n    Math.max(\n      value.length,\n      ...(text === undefined ? words.map((w) => w.length) : [0])\n    );\n  const padded = value.padEnd(size, \" \");\n\n  return (\n    <span\n      className={cn(\n        \"inline-flex gap-[0.08em] font-semibold text-3xl tabular-nums leading-none sm:text-5xl\",\n        className\n      )}\n    >\n      {/* Only announce text the app sets; a board cycling on its own would\n          talk over the reader every few seconds. */}\n      <span\n        aria-live={text === undefined ? \"off\" : \"polite\"}\n        className=\"sr-only\"\n      >\n        {value}\n      </span>\n      {[...padded].map((char, i) => (\n        <Tile\n          className={tileClassName}\n          delay={i * STAGGER}\n          key={i.toString()}\n          reduceMotion={reduceMotion}\n          target={char}\n        />\n      ))}\n    </span>\n  );\n}\n",
      "path": "/components/kokonutui/split-flap-text.tsx",
      "target": "components/kokonutui/split-flap-text.tsx"
    }
  ]
}