{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "Shuffle-JS-TW",
	"title": "Shuffle",
	"description": "Animated text reveal where characters shuffle before settling.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:component",
			"path": "Shuffle/Shuffle.jsx",
			"content": "import React, { useRef, useEffect, useState, useMemo } from 'react';\nimport { gsap } from 'gsap';\nimport { ScrollTrigger } from 'gsap/ScrollTrigger';\nimport { SplitText as GSAPSplitText } from 'gsap/SplitText';\nimport { useGSAP } from '@gsap/react';\n\ngsap.registerPlugin(ScrollTrigger, GSAPSplitText, useGSAP);\n\nconst Shuffle = ({\n  text,\n  className = '',\n  style = {},\n  shuffleDirection = 'right',\n  duration = 0.35,\n  maxDelay = 0,\n  ease = 'power3.out',\n  threshold = 0.1,\n  rootMargin = '-100px',\n  tag = 'p',\n  textAlign = 'center',\n  onShuffleComplete,\n  shuffleTimes = 1,\n  animationMode = 'evenodd',\n  loop = false,\n  loopDelay = 0,\n  stagger = 0.03,\n  scrambleCharset = '',\n  colorFrom,\n  colorTo,\n  triggerOnce = true,\n  respectReducedMotion = true,\n  triggerOnHover = true\n}) => {\n  const ref = useRef(null);\n  const [fontsLoaded, setFontsLoaded] = useState(false);\n  const [ready, setReady] = useState(false);\n\n  const splitRef = useRef(null);\n  const wrappersRef = useRef([]);\n  const tlRef = useRef(null);\n  const playingRef = useRef(false);\n  const hoverHandlerRef = useRef(null);\n\n  const userHasFont = useMemo(\n    () => (style && style.fontFamily) || (className && /font[-[]/i.test(className)),\n    [style, className]\n  );\n\n  const scrollTriggerStart = useMemo(() => {\n    const startPct = (1 - threshold) * 100;\n    const mm = /^(-?\\d+(?:\\.\\d+)?)(px|em|rem|%)?$/.exec(rootMargin || '');\n    const mv = mm ? parseFloat(mm[1]) : 0;\n    const mu = mm ? mm[2] || 'px' : 'px';\n    const sign = mv === 0 ? '' : mv < 0 ? `-=${Math.abs(mv)}${mu}` : `+=${mv}${mu}`;\n    return `top ${startPct}%${sign}`;\n  }, [threshold, rootMargin]);\n\n  useEffect(() => {\n    if ('fonts' in document) {\n      if (document.fonts.status === 'loaded') setFontsLoaded(true);\n      else document.fonts.ready.then(() => setFontsLoaded(true));\n    } else setFontsLoaded(true);\n  }, []);\n\n  useGSAP(\n    () => {\n      if (!ref.current || !text || !fontsLoaded) return;\n\n      if (respectReducedMotion && window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {\n        onShuffleComplete?.();\n        return;\n      }\n\n      const el = ref.current;\n\n      let computedFont = '';\n      if (userHasFont) {\n        computedFont = style.fontFamily || getComputedStyle(el).fontFamily || '';\n      } else {\n        computedFont = `'Press Start 2P', sans-serif`;\n      }\n\n      const start = scrollTriggerStart;\n\n      const removeHover = () => {\n        if (hoverHandlerRef.current && ref.current) {\n          ref.current.removeEventListener('mouseenter', hoverHandlerRef.current);\n          hoverHandlerRef.current = null;\n        }\n      };\n\n      const teardown = () => {\n        if (tlRef.current) {\n          tlRef.current.kill();\n          tlRef.current = null;\n        }\n        if (wrappersRef.current.length) {\n          wrappersRef.current.forEach(wrap => {\n            const inner = wrap.firstElementChild;\n            const orig = inner?.querySelector('[data-orig=\"1\"]');\n            if (orig && wrap.parentNode) wrap.parentNode.replaceChild(orig, wrap);\n          });\n          wrappersRef.current = [];\n        }\n        try {\n          splitRef.current?.revert();\n        } catch {\n          /* noop */\n        }\n        splitRef.current = null;\n        playingRef.current = false;\n      };\n\n      const build = () => {\n        teardown();\n\n        splitRef.current = new GSAPSplitText(el, {\n          type: 'chars',\n          charsClass: 'shuffle-char',\n          wordsClass: 'shuffle-word',\n          linesClass: 'shuffle-line',\n          smartWrap: true,\n          reduceWhiteSpace: false\n        });\n\n        const chars = splitRef.current.chars || [];\n        wrappersRef.current = [];\n\n        const rolls = Math.max(1, Math.floor(shuffleTimes));\n        const rand = set => set.charAt(Math.floor(Math.random() * set.length)) || '';\n        const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n        const metricsContext = isVertical ? document.createElement('canvas').getContext('2d') : null;\n\n        const measureVerticalCell = (node, lineBoxHeight) => {\n          const computed = window.getComputedStyle(node);\n          let fontHeight = 0;\n\n          if (metricsContext) {\n            metricsContext.font = [\n              computed.fontStyle,\n              computed.fontVariant,\n              computed.fontWeight,\n              computed.fontSize,\n              computed.fontFamily\n            ].join(' ');\n\n            const sample = `${node.textContent || 'M'}${scrambleCharset}`;\n            const metrics = metricsContext.measureText(sample);\n            const ascent = metrics.fontBoundingBoxAscent;\n            const descent = metrics.fontBoundingBoxDescent;\n            if (Number.isFinite(ascent) && Number.isFinite(descent)) fontHeight = ascent + descent;\n          }\n\n          if (!fontHeight) {\n            const probe = node.cloneNode(true);\n            probe.textContent = `${node.textContent || 'M'}${scrambleCharset}`;\n            Object.assign(probe.style, {\n              position: 'absolute',\n              visibility: 'hidden',\n              pointerEvents: 'none',\n              width: 'auto',\n              height: 'auto',\n              whiteSpace: 'nowrap',\n              lineHeight: 'normal',\n              fontFamily: computed.fontFamily,\n              fontSize: computed.fontSize,\n              fontStyle: computed.fontStyle,\n              fontVariant: computed.fontVariant,\n              fontWeight: computed.fontWeight,\n              fontStretch: computed.fontStretch\n            });\n            document.body.appendChild(probe);\n            fontHeight = probe.getBoundingClientRect().height;\n            probe.remove();\n          }\n\n          const overflow = Math.max(0, Math.ceil(fontHeight - lineBoxHeight));\n          const padTop = Math.floor(overflow / 2);\n          return { cellHeight: lineBoxHeight + overflow, padTop, padBottom: overflow - padTop };\n        };\n\n        chars.forEach(ch => {\n          const parent = ch.parentElement;\n          if (!parent) return;\n\n          const w = ch.getBoundingClientRect().width;\n          const h = ch.getBoundingClientRect().height;\n          if (!w) return;\n\n          const { cellHeight, padTop, padBottom } = isVertical\n            ? measureVerticalCell(ch, h)\n            : { cellHeight: h, padTop: 0, padBottom: 0 };\n\n          const wrap = document.createElement('span');\n          wrap.className = 'inline-block overflow-hidden text-left';\n          Object.assign(wrap.style, {\n            width: w + 'px',\n            height: isVertical ? cellHeight + 'px' : 'auto',\n            marginTop: isVertical ? -padTop + 'px' : '0',\n            marginBottom: isVertical ? -padBottom + 'px' : '0',\n            verticalAlign: 'bottom'\n          });\n\n          const inner = document.createElement('span');\n          inner.className =\n            'inline-block will-change-transform origin-left transform-gpu ' +\n            (isVertical ? 'whitespace-normal' : 'whitespace-nowrap');\n\n          parent.insertBefore(wrap, ch);\n          wrap.appendChild(inner);\n\n          const firstOrig = ch.cloneNode(true);\n          firstOrig.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n          Object.assign(firstOrig.style, {\n            alignItems: isVertical ? 'center' : '',\n            justifyContent: isVertical ? 'center' : '',\n            height: isVertical ? cellHeight + 'px' : '',\n            lineHeight: isVertical ? h + 'px' : '',\n            width: w + 'px',\n            fontFamily: computedFont\n          });\n\n          ch.setAttribute('data-orig', '1');\n          ch.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n          Object.assign(ch.style, {\n            alignItems: isVertical ? 'center' : '',\n            justifyContent: isVertical ? 'center' : '',\n            height: isVertical ? cellHeight + 'px' : '',\n            lineHeight: isVertical ? h + 'px' : '',\n            width: w + 'px',\n            fontFamily: computedFont\n          });\n\n          inner.appendChild(firstOrig);\n          for (let k = 0; k < rolls; k++) {\n            const c = ch.cloneNode(true);\n            if (scrambleCharset) c.textContent = rand(scrambleCharset);\n            c.className = 'text-left ' + (isVertical ? 'flex' : 'inline-block');\n            Object.assign(c.style, {\n              alignItems: isVertical ? 'center' : '',\n              justifyContent: isVertical ? 'center' : '',\n              height: isVertical ? cellHeight + 'px' : '',\n              lineHeight: isVertical ? h + 'px' : '',\n              width: w + 'px',\n              fontFamily: computedFont\n            });\n            inner.appendChild(c);\n          }\n          inner.appendChild(ch);\n\n          const steps = rolls + 1;\n\n          if (shuffleDirection === 'right' || shuffleDirection === 'down') {\n            const firstCopy = inner.firstElementChild;\n            const real = inner.lastElementChild;\n            if (real) inner.insertBefore(real, inner.firstChild);\n            if (firstCopy) inner.appendChild(firstCopy);\n          }\n\n          let startX = 0;\n          let finalX = 0;\n          let startY = 0;\n          let finalY = 0;\n\n          if (shuffleDirection === 'right') {\n            startX = -steps * w;\n            finalX = 0;\n          } else if (shuffleDirection === 'left') {\n            startX = 0;\n            finalX = -steps * w;\n          } else if (shuffleDirection === 'down') {\n            startY = -steps * cellHeight;\n            finalY = 0;\n          } else if (shuffleDirection === 'up') {\n            startY = 0;\n            finalY = -steps * cellHeight;\n          }\n\n          if (shuffleDirection === 'left' || shuffleDirection === 'right') {\n            gsap.set(inner, { x: startX, y: 0, force3D: true });\n            inner.setAttribute('data-start-x', String(startX));\n            inner.setAttribute('data-final-x', String(finalX));\n          } else {\n            gsap.set(inner, { x: 0, y: startY, force3D: true });\n            inner.setAttribute('data-start-y', String(startY));\n            inner.setAttribute('data-final-y', String(finalY));\n          }\n\n          if (colorFrom) inner.style.color = colorFrom;\n          wrappersRef.current.push(wrap);\n        });\n      };\n\n      const inners = () => wrappersRef.current.map(w => w.firstElementChild);\n\n      const randomizeScrambles = () => {\n        if (!scrambleCharset) return;\n        wrappersRef.current.forEach(w => {\n          const strip = w.firstElementChild;\n          if (!strip) return;\n          const kids = Array.from(strip.children);\n          for (let i = 1; i < kids.length - 1; i++) {\n            kids[i].textContent = scrambleCharset.charAt(Math.floor(Math.random() * scrambleCharset.length));\n          }\n        });\n      };\n\n      const cleanupToStill = () => {\n        wrappersRef.current.forEach(w => {\n          const strip = w.firstElementChild;\n          if (!strip) return;\n          const real = strip.querySelector('[data-orig=\"1\"]');\n          if (!real) return;\n          strip.replaceChildren(real);\n          strip.style.transform = 'none';\n          strip.style.willChange = 'auto';\n        });\n      };\n\n      const play = () => {\n        const strips = inners();\n        if (!strips.length) return;\n\n        playingRef.current = true;\n        const isVertical = shuffleDirection === 'up' || shuffleDirection === 'down';\n\n        const tl = gsap.timeline({\n          smoothChildTiming: true,\n          repeat: loop ? -1 : 0,\n          repeatDelay: loop ? loopDelay : 0,\n          onRepeat: () => {\n            if (scrambleCharset) randomizeScrambles();\n            if (isVertical) {\n              gsap.set(strips, { y: (i, t) => parseFloat(t.getAttribute('data-start-y') || '0') });\n            } else {\n              gsap.set(strips, { x: (i, t) => parseFloat(t.getAttribute('data-start-x') || '0') });\n            }\n            onShuffleComplete?.();\n          },\n          onComplete: () => {\n            playingRef.current = false;\n            if (!loop) {\n              cleanupToStill();\n              if (colorTo) gsap.set(strips, { color: colorTo });\n              onShuffleComplete?.();\n              armHover();\n            }\n          }\n        });\n\n        const addTween = (targets, at) => {\n          const vars = {\n            duration,\n            ease,\n            force3D: true,\n            stagger: animationMode === 'evenodd' ? stagger : 0\n          };\n          if (isVertical) {\n            vars.y = (i, t) => parseFloat(t.getAttribute('data-final-y') || '0');\n          } else {\n            vars.x = (i, t) => parseFloat(t.getAttribute('data-final-x') || '0');\n          }\n\n          tl.to(targets, vars, at);\n\n          if (colorFrom && colorTo) tl.to(targets, { color: colorTo, duration, ease }, at);\n        };\n\n        if (animationMode === 'evenodd') {\n          const odd = strips.filter((_, i) => i % 2 === 1);\n          const even = strips.filter((_, i) => i % 2 === 0);\n          const oddTotal = duration + Math.max(0, odd.length - 1) * stagger;\n          const evenStart = odd.length ? oddTotal * 0.7 : 0;\n          if (odd.length) addTween(odd, 0);\n          if (even.length) addTween(even, evenStart);\n        } else {\n          strips.forEach(strip => {\n            const d = Math.random() * maxDelay;\n            const vars = {\n              duration,\n              ease,\n              force3D: true\n            };\n            if (isVertical) {\n              vars.y = parseFloat(strip.getAttribute('data-final-y') || '0');\n            } else {\n              vars.x = parseFloat(strip.getAttribute('data-final-x') || '0');\n            }\n            tl.to(strip, vars, d);\n            if (colorFrom && colorTo) tl.fromTo(strip, { color: colorFrom }, { color: colorTo, duration, ease }, d);\n          });\n        }\n\n        tlRef.current = tl;\n      };\n\n      const armHover = () => {\n        if (!triggerOnHover || !ref.current) return;\n        removeHover();\n        const handler = () => {\n          if (playingRef.current) return;\n          build();\n          if (scrambleCharset) randomizeScrambles();\n          play();\n        };\n        hoverHandlerRef.current = handler;\n        ref.current.addEventListener('mouseenter', handler);\n      };\n\n      const create = () => {\n        build();\n        if (scrambleCharset) randomizeScrambles();\n        play();\n        armHover();\n        setReady(true);\n      };\n\n      const st = ScrollTrigger.create({ trigger: el, start, once: triggerOnce, onEnter: create });\n\n      return () => {\n        st.kill();\n        removeHover();\n        teardown();\n        setReady(false);\n      };\n    },\n    {\n      dependencies: [\n        text,\n        duration,\n        maxDelay,\n        ease,\n        scrollTriggerStart,\n        fontsLoaded,\n        shuffleDirection,\n        shuffleTimes,\n        animationMode,\n        loop,\n        loopDelay,\n        stagger,\n        scrambleCharset,\n        colorFrom,\n        colorTo,\n        triggerOnce,\n        respectReducedMotion,\n        triggerOnHover,\n        onShuffleComplete,\n        userHasFont\n      ],\n      scope: ref\n    }\n  );\n\n  const baseTw = 'inline-block whitespace-normal break-words will-change-transform uppercase text-[4rem] leading-none';\n  const classes = useMemo(\n    () => `${baseTw} ${ready ? 'visible' : 'invisible'} ${className}`.trim(),\n    [baseTw, ready, className]\n  );\n  const Tag = tag || 'p';\n  const commonStyle = useMemo(() => ({ textAlign, ...style }), [textAlign, style]);\n\n  return React.createElement(Tag, { ref: ref, className: classes, style: commonStyle }, text);\n};\n\nexport default Shuffle;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"gsap@^3.13.0",
		"@gsap/react@^2.1.2"
	]
}