{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "TextLoop-JS-CSS",
	"title": "TextLoop",
	"description": "A seamless text marquee that flows along curved SVG paths.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "TextLoop.css",
			"target": "@components/TextLoop.css",
			"content": ".text-loop {\n  position: relative;\n  width: 100%;\n  overflow: hidden;\n}\n\n.text-loop-svg {\n  display: block;\n  width: 100%;\n  height: auto;\n}\n\n.text-loop-text {\n  user-select: none;\n}\n\n.text-loop-measure {\n  visibility: hidden;\n  pointer-events: none;\n}\n"
		},
		{
			"type": "registry:component",
			"path": "TextLoop.jsx",
			"content": "import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react';\nimport { gsap } from 'gsap';\n\nimport './TextLoop.css';\n\nconst VIEW_W = 1200;\nconst VIEW_H = 520;\nconst CX = VIEW_W / 2;\nconst CY = VIEW_H / 2;\nconst EDGE_PAD = 6;\n\nconst buildPath = (shape, curviness, ribbonWidth) => {\n  const c = Math.max(0, curviness);\n  const room = Math.max(20, CY - Math.max(0, ribbonWidth) / 2 - EDGE_PAD);\n\n  switch (shape) {\n    case 'circle': {\n      const r = Math.min(90 + c * 0.95, room);\n      return `M ${CX - r} ${CY} A ${r} ${r} 0 1 1 ${CX + r} ${CY} A ${r} ${r} 0 1 1 ${CX - r} ${CY} Z`;\n    }\n    case 'infinity': {\n      const r = 150 + c * 1.4;\n      const h = Math.min(60 + c * 0.95, room);\n      return [\n        `M ${CX} ${CY}`,\n        `C ${CX + r * 0.55} ${CY - h} ${CX + r} ${CY - h} ${CX + r} ${CY}`,\n        `C ${CX + r} ${CY + h} ${CX + r * 0.55} ${CY + h} ${CX} ${CY}`,\n        `C ${CX - r * 0.55} ${CY - h} ${CX - r} ${CY - h} ${CX - r} ${CY}`,\n        `C ${CX - r} ${CY + h} ${CX - r * 0.55} ${CY + h} ${CX} ${CY}`,\n        'Z'\n      ].join(' ');\n    }\n    case 'arch': {\n      const rise = Math.min(120 + c * 1.1, room * 2);\n      return `M 120 ${CY + rise / 2} Q ${CX} ${CY - rise * 1.5} ${VIEW_W - 120} ${CY + rise / 2}`;\n    }\n    case 'line':\n      return `M -320 ${CY} L ${VIEW_W + 320} ${CY}`;\n    case 'wave':\n    default: {\n      const a = Math.min(c * 2.2, room * 2);\n      return `M -320 ${CY} Q -160 ${CY - a} 0 ${CY} T 320 ${CY} T 640 ${CY} T 960 ${CY} T 1280 ${CY} T ${VIEW_W + 320} ${CY}`;\n    }\n  }\n};\n\nconst TextLoop = ({\n  text = 'React ✦ Bits',\n  shape = 'wave',\n  path,\n  speed = 90,\n  direction = 'forward',\n  separator = '✦',\n  curviness = 90,\n  fontSize = 46,\n  fontWeight = 800,\n  letterSpacing = 2,\n  uppercase = true,\n  color = '#ffffff',\n  ribbon = true,\n  ribbonColor = '#5227FF',\n  ribbonWidth = 86,\n  pauseOnHover = true,\n  className = '',\n  style = {}\n}) => {\n  const rootRef = useRef(null);\n  const pathRef = useRef(null);\n  const measureRef = useRef(null);\n  const headRef = useRef(null);\n  const tailRef = useRef(null);\n\n  const [metrics, setMetrics] = useState({ length: 0, reps: 1 });\n\n  const rawId = useId();\n  const pathId = `text-loop-${rawId.replace(/:/g, '')}`;\n\n  const d = useMemo(() => path || buildPath(shape, curviness, ribbonWidth), [path, shape, curviness, ribbonWidth]);\n\n  const unit = useMemo(() => {\n    const base = uppercase ? String(text).toUpperCase() : String(text);\n    const gap = separator ? `\\u00A0${separator}\\u00A0` : '\\u00A0\\u00A0\\u00A0';\n    return `${base}${gap}`;\n  }, [text, separator, uppercase]);\n\n  const textStyle = useMemo(\n    () => ({ fontSize: `${fontSize}px`, fontWeight, letterSpacing: `${letterSpacing}px` }),\n    [fontSize, fontWeight, letterSpacing]\n  );\n\n  useLayoutEffect(() => {\n    const pathEl = pathRef.current;\n    const measureEl = measureRef.current;\n    if (!pathEl || !measureEl) return undefined;\n\n    let cancelled = false;\n\n    const measure = () => {\n      if (cancelled) return;\n      let length = 0;\n      let unitWidth = 0;\n      try {\n        length = pathEl.getTotalLength();\n        unitWidth = measureEl.getComputedTextLength();\n      } catch {\n        return;\n      }\n      if (!length) return;\n\n      const reps = unitWidth > 0 ? Math.max(1, Math.round(length / unitWidth)) : 1;\n      setMetrics(prev => (prev.length === length && prev.reps === reps ? prev : { length, reps }));\n    };\n\n    measure();\n    if (typeof document !== 'undefined' && document.fonts?.ready) {\n      document.fonts.ready.then(measure).catch(() => {});\n    }\n\n    return () => {\n      cancelled = true;\n    };\n  }, [d, unit, fontSize, fontWeight, letterSpacing]);\n\n  useEffect(() => {\n    const { length } = metrics;\n    const head = headRef.current;\n    const tail = tailRef.current;\n    if (!head || !tail || !length) return undefined;\n\n    const apply = offset => {\n      const partner = offset >= 0 ? offset - length : offset + length;\n      head.setAttribute('startOffset', String(offset));\n      tail.setAttribute('startOffset', String(partner));\n    };\n\n    apply(0);\n\n    const prefersReduced =\n      typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;\n    if (prefersReduced || speed <= 0) return undefined;\n\n    const state = { offset: 0 };\n    const tween = gsap.to(state, {\n      offset: direction === 'reverse' ? -length : length,\n      duration: length / speed,\n      ease: 'none',\n      repeat: -1,\n      onUpdate: () => apply(state.offset)\n    });\n\n    const root = rootRef.current;\n    const pause = () => tween.pause();\n    const resume = () => tween.resume();\n\n    if (pauseOnHover && root) {\n      root.addEventListener('pointerenter', pause);\n      root.addEventListener('pointerleave', resume);\n    }\n\n    return () => {\n      tween.kill();\n      if (pauseOnHover && root) {\n        root.removeEventListener('pointerenter', pause);\n        root.removeEventListener('pointerleave', resume);\n      }\n    };\n  }, [metrics, speed, direction, pauseOnHover]);\n\n  const loopText = unit.repeat(metrics.reps);\n  const fitLength = metrics.length || undefined;\n\n  return (\n    <div ref={rootRef} className={`text-loop ${className}`.trim()} style={style}>\n      <svg\n        className=\"text-loop-svg\"\n        viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}\n        preserveAspectRatio=\"xMidYMid meet\"\n        role=\"img\"\n        aria-label={text}\n      >\n        <path\n          ref={pathRef}\n          id={pathId}\n          d={d}\n          fill=\"none\"\n          stroke={ribbon ? ribbonColor : 'none'}\n          strokeWidth={ribbon ? ribbonWidth : 0}\n          strokeLinecap=\"round\"\n          strokeLinejoin=\"round\"\n        />\n\n        <text ref={measureRef} className=\"text-loop-measure\" style={textStyle} aria-hidden=\"true\">\n          {unit}\n        </text>\n\n        <text className=\"text-loop-text\" style={textStyle} fill={color} dominantBaseline=\"central\" aria-hidden=\"true\">\n          <textPath ref={headRef} href={`#${pathId}`} startOffset={0} textLength={fitLength} lengthAdjust=\"spacing\">\n            {loopText}\n          </textPath>\n        </text>\n\n        <text className=\"text-loop-text\" style={textStyle} fill={color} dominantBaseline=\"central\" aria-hidden=\"true\">\n          <textPath ref={tailRef} href={`#${pathId}`} startOffset={0} textLength={fitLength} lengthAdjust=\"spacing\">\n            {loopText}\n          </textPath>\n        </text>\n      </svg>\n    </div>\n  );\n};\n\nexport default TextLoop;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"gsap@^3.13.0"
	]
}