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