{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "SplitFlapText-JS-TW",
	"title": "SplitFlapText",
	"description": "Mechanical split-flap departure board that clacks through to each new phrase.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "SplitFlapText/SplitFlapText.jsx",
			"content": "import { useEffect, useMemo, useRef, useState } from 'react';\n\nconst DEFAULT_WORDS = ['LAUNCH READY', 'SYNC ONLINE', 'SIGNAL LIVE'];\n\nconst CHARSETS = {\n  alpha: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',\n  alphanumeric: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',\n  numeric: '0123456789'\n};\n\nconst styles = `\n.split-flap-text{font-family:'SFMono-Regular','Roboto Mono','Cascadia Code','Liberation Mono',Menlo,monospace;font-size:var(--split-flap-font-size,52px);font-weight:760;line-height:1;letter-spacing:.035em;font-variant-numeric:tabular-nums}\n.split-flap-text__tile{position:relative;width:.78em;height:1.08em;overflow:hidden;border-radius:var(--split-flap-radius,8px);background:radial-gradient(circle at 50% 0%,rgba(255,255,255,.16),transparent 44%),linear-gradient(180deg,color-mix(in srgb,var(--split-flap-tile-color,#111827) 82%,white),var(--split-flap-tile-color,#111827));box-shadow:0 .035em .08em rgba(255,255,255,.08) inset,0 -.05em .1em rgba(0,0,0,.38) inset,0 .16em .38em rgba(0,0,0,.28);perspective:520px;transform-style:preserve-3d;isolation:isolate}\n.split-flap-text__tile:before{content:'';position:absolute;z-index:8;top:calc(50% - .5px);left:0;width:100%;height:1px;background:linear-gradient(90deg,transparent,rgba(255,255,255,.18) 18%,rgba(0,0,0,.64) 50%,rgba(255,255,255,.14) 82%,transparent);box-shadow:0 -1px 0 rgba(255,255,255,.08),0 1px 0 rgba(0,0,0,.5);pointer-events:none}\n.split-flap-text__tile:after{content:'';position:absolute;inset:0;z-index:9;border:1px solid rgba(255,255,255,.08);border-radius:inherit;box-shadow:0 0 0 1px rgba(0,0,0,.2) inset;pointer-events:none}\n.split-flap-text__half,.split-flap-text__flap{position:absolute;left:0;width:100%;height:50%;overflow:hidden;background:linear-gradient(180deg,rgba(255,255,255,.07),transparent 34%),var(--split-flap-tile-color,#111827);backface-visibility:hidden}\n.split-flap-text__half--top,.split-flap-text__flap--front{top:0}\n.split-flap-text__half--bottom,.split-flap-text__flap--back{bottom:0;background:linear-gradient(0deg,rgba(255,255,255,.06),transparent 38%),color-mix(in srgb,var(--split-flap-tile-color,#111827) 92%,black)}\n.split-flap-text__char{position:absolute;left:0;width:100%;height:200%;display:flex;align-items:center;justify-content:center;color:var(--split-flap-text-color,#f8fafc);text-shadow:0 .025em 0 rgba(255,255,255,.16),0 .09em .16em rgba(0,0,0,.42)}\n.split-flap-text__half--top .split-flap-text__char,.split-flap-text__flap--front .split-flap-text__char{top:0}\n.split-flap-text__half--bottom .split-flap-text__char,.split-flap-text__flap--back .split-flap-text__char{bottom:0}\n.split-flap-text__flap{z-index:6;will-change:transform,filter;transform-style:preserve-3d}\n.split-flap-text__flap--front{transform-origin:center bottom;animation:split-flap-front var(--split-flap-flip-duration,.12s) cubic-bezier(.23,1,.32,1) both}\n.split-flap-text__flap--back{transform-origin:center top;transform:rotateX(90deg);animation:split-flap-back var(--split-flap-flip-duration,.12s) cubic-bezier(.23,1,.32,1) both}\n@keyframes split-flap-front{0%{transform:rotateX(0deg);filter:brightness(1.08)}100%{transform:rotateX(-90deg);filter:brightness(.52)}}\n@keyframes split-flap-back{0%,45%{transform:rotateX(90deg);filter:brightness(.58)}100%{transform:rotateX(0deg);filter:brightness(1)}}\n@media (prefers-reduced-motion:reduce){.split-flap-text__flap{animation:none!important}}\n`;\n\nconst toCssUnit = value => (typeof value === 'number' ? `${value}px` : value);\n\nconst resolveCharset = charset => {\n  if (CHARSETS[charset]) return CHARSETS[charset];\n  return typeof charset === 'string' && charset.length > 0 ? charset : CHARSETS.alphanumeric;\n};\n\nconst normalizePhrase = (phrase, width) => {\n  const safe = String(phrase ?? '');\n  return safe.padEnd(width, ' ').slice(0, width);\n};\n\nconst createTiles = phrase =>\n  phrase.split('').map(char => ({\n    current: char,\n    next: char,\n    flipping: false,\n    tick: 0\n  }));\n\nconst sampleChar = charset => charset.charAt(Math.floor(Math.random() * charset.length)) || ' ';\n\nconst buildSequence = (target, flips, charset) => {\n  const steps = [];\n  for (let i = 0; i < flips; i += 1) {\n    steps.push(sampleChar(charset));\n  }\n  steps.push(target);\n  return steps;\n};\n\nconst usePrefersReducedMotion = () => {\n  const [prefersReduced, setPrefersReduced] = useState(false);\n\n  useEffect(() => {\n    if (typeof window === 'undefined' || !window.matchMedia) return;\n\n    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');\n    const handleChange = () => setPrefersReduced(mediaQuery.matches);\n\n    handleChange();\n    mediaQuery.addEventListener('change', handleChange);\n\n    return () => mediaQuery.removeEventListener('change', handleChange);\n  }, []);\n\n  return prefersReduced;\n};\n\nconst SplitFlapText = ({\n  words = ['LAUNCH READY', 'SYNC ONLINE', 'SIGNAL LIVE'],\n  text,\n  flipDuration = 0.12,\n  stagger = 0.06,\n  cycleDelay = 2400,\n  charset = 'alphanumeric',\n  flipsPerChar = 8,\n  tileColor = '#111827',\n  textColor = '#f8fafc',\n  tileRadius = 8,\n  gap = 6,\n  fontSize = 52,\n  loop = true,\n  padTo = 12,\n  className = '',\n  style = {},\n  ...props\n}) => {\n  const prefersReducedMotion = usePrefersReducedMotion();\n  const rafRef = useRef(null);\n  const cycleTimerRef = useRef(null);\n  const currentTextRef = useRef('');\n\n  const sourceWords = Array.isArray(words) && words.length > 0 ? words : DEFAULT_WORDS;\n  const phrasesKey = typeof text === 'string' ? text : sourceWords.map(word => String(word ?? '')).join('\\u001f');\n  const phrases = useMemo(() => phrasesKey.split('\\u001f'), [phrasesKey]);\n\n  const width = useMemo(() => {\n    const longest = phrases.reduce((max, phrase) => Math.max(max, phrase.length), 1);\n    return Math.max(1, Math.ceil(Number(padTo) || 0), longest);\n  }, [padTo, phrases]);\n\n  const normalizedPhrases = useMemo(() => phrases.map(phrase => normalizePhrase(phrase, width)), [phrases, width]);\n\n  const [tiles, setTiles] = useState(() => createTiles(normalizedPhrases[0] || ''));\n\n  useEffect(() => {\n    const clearAnimation = () => {\n      if (rafRef.current) {\n        cancelAnimationFrame(rafRef.current);\n        rafRef.current = null;\n      }\n\n      if (cycleTimerRef.current) {\n        clearTimeout(cycleTimerRef.current);\n        cycleTimerRef.current = null;\n      }\n    };\n\n    clearAnimation();\n\n    const firstPhrase = normalizedPhrases[0] || '';\n    currentTextRef.current = firstPhrase;\n    setTiles(createTiles(firstPhrase));\n\n    if (normalizedPhrases.length <= 1 || typeof window === 'undefined') {\n      return clearAnimation;\n    }\n\n    let phraseIndex = 0;\n    let cancelled = false;\n\n    const safeFlipMs = Math.max(40, (Number(flipDuration) || 0.12) * 1000);\n    const safeStaggerMs = Math.max(0, (Number(stagger) || 0) * 1000);\n    const safeCycleDelay = Math.max(400, Number(cycleDelay) || 2400);\n    const safeFlips = Math.max(0, Math.floor(Number(flipsPerChar) || 0));\n    const activeCharset = resolveCharset(charset);\n\n    const animateTo = targetPhrase => {\n      if (prefersReducedMotion) {\n        currentTextRef.current = targetPhrase;\n        setTiles(createTiles(targetPhrase));\n        return 0;\n      }\n\n      const fromPhrase = normalizePhrase(currentTextRef.current, width);\n      const targetChars = targetPhrase.split('');\n\n      const plans = targetChars\n        .map((targetChar, index) => {\n          const fromChar = fromPhrase[index] || ' ';\n          if (fromChar === targetChar) return null;\n\n          return {\n            index,\n            from: fromChar,\n            target: targetChar,\n            sequence: buildSequence(targetChar, safeFlips, activeCharset),\n            start: index * safeStaggerMs,\n            step: -1,\n            done: false\n          };\n        })\n        .filter(Boolean);\n\n      if (!plans.length) {\n        currentTextRef.current = targetPhrase;\n        setTiles(createTiles(targetPhrase));\n        return 0;\n      }\n\n      const totalDuration = plans.reduce(\n        (max, plan) => Math.max(max, plan.start + plan.sequence.length * safeFlipMs),\n        0\n      );\n      const startedAt = performance.now();\n\n      const updateTiles = updates => {\n        setTiles(previous => {\n          const nextTiles = [...previous];\n          updates.forEach(update => {\n            const tile = nextTiles[update.index];\n            if (!tile) return;\n\n            nextTiles[update.index] = {\n              current: update.current,\n              next: update.next,\n              flipping: !update.done,\n              tick: tile.tick + 1\n            };\n          });\n          return nextTiles;\n        });\n      };\n\n      const tick = now => {\n        if (cancelled) return;\n\n        const elapsed = now - startedAt;\n        const updates = [];\n        let shouldContinue = false;\n\n        plans.forEach(plan => {\n          const localElapsed = elapsed - plan.start;\n\n          if (localElapsed < 0) {\n            shouldContinue = true;\n            return;\n          }\n\n          const step = Math.floor(localElapsed / safeFlipMs);\n\n          if (step < plan.sequence.length) {\n            shouldContinue = true;\n\n            if (step !== plan.step) {\n              plan.step = step;\n              updates.push({\n                index: plan.index,\n                current: step === 0 ? plan.from : plan.sequence[step - 1],\n                next: plan.sequence[step],\n                done: false\n              });\n            }\n          } else if (!plan.done) {\n            plan.done = true;\n            updates.push({\n              index: plan.index,\n              current: plan.target,\n              next: plan.target,\n              done: true\n            });\n          }\n        });\n\n        if (updates.length > 0) updateTiles(updates);\n\n        if (shouldContinue) {\n          rafRef.current = requestAnimationFrame(tick);\n        } else {\n          currentTextRef.current = targetPhrase;\n          rafRef.current = null;\n        }\n      };\n\n      rafRef.current = requestAnimationFrame(tick);\n      return totalDuration;\n    };\n\n    const scheduleNext = delay => {\n      cycleTimerRef.current = window.setTimeout(() => {\n        if (cancelled) return;\n\n        const nextIndex = phraseIndex + 1;\n\n        if (nextIndex >= normalizedPhrases.length && !loop) return;\n\n        phraseIndex = nextIndex % normalizedPhrases.length;\n        const animationDuration = animateTo(normalizedPhrases[phraseIndex]);\n        scheduleNext(safeCycleDelay + animationDuration);\n      }, delay);\n    };\n\n    scheduleNext(safeCycleDelay);\n\n    return () => {\n      cancelled = true;\n      clearAnimation();\n    };\n  }, [normalizedPhrases, width, loop, cycleDelay, flipDuration, stagger, flipsPerChar, charset, prefersReducedMotion]);\n\n  const settledText = tiles\n    .map(tile => tile.current)\n    .join('')\n    .trimEnd();\n  const componentStyle = {\n    '--split-flap-tile-color': tileColor,\n    '--split-flap-text-color': textColor,\n    '--split-flap-radius': toCssUnit(tileRadius),\n    '--split-flap-gap': toCssUnit(gap),\n    '--split-flap-font-size': toCssUnit(fontSize),\n    '--split-flap-flip-duration': `${Math.max(0.04, Number(flipDuration) || 0.12)}s`,\n    ...style\n  };\n\n  return (\n    <>\n      <style>{styles}</style>\n      <div\n        className={`split-flap-text inline-flex items-center whitespace-pre select-none ${className}`.trim()}\n        style={componentStyle}\n        role=\"text\"\n        aria-label={settledText || undefined}\n        {...props}\n      >\n        {tiles.map((tile, index) => (\n          <span className=\"split-flap-text__tile\" aria-hidden=\"true\" key={`${index}-${tiles.length}`}>\n            <span className=\"split-flap-text__half split-flap-text__half--top\">\n              <span className=\"split-flap-text__char\">{tile.current === ' ' ? '\\u00A0' : tile.current}</span>\n            </span>\n            <span className=\"split-flap-text__half split-flap-text__half--bottom\">\n              <span className=\"split-flap-text__char\">{tile.flipping ? tile.next : tile.current}</span>\n            </span>\n\n            {tile.flipping && (\n              <>\n                <span\n                  className=\"split-flap-text__flap split-flap-text__flap--front\"\n                  key={`front-${index}-${tile.tick}`}\n                >\n                  <span className=\"split-flap-text__char\">{tile.current === ' ' ? '\\u00A0' : tile.current}</span>\n                </span>\n                <span className=\"split-flap-text__flap split-flap-text__flap--back\" key={`back-${index}-${tile.tick}`}>\n                  <span className=\"split-flap-text__char\">{tile.next === ' ' ? '\\u00A0' : tile.next}</span>\n                </span>\n              </>\n            )}\n          </span>\n        ))}\n      </div>\n    </>\n  );\n};\n\nexport default SplitFlapText;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": []
}