{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scroll-text-path",
  "type": "registry:ui",
  "title": "Scroll Text Path",
  "description": "Type that travels along a curved SVG path — wave, arc, loop or your own — steered by scroll progress.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "registry/wancraft/scroll-text-path.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useMotionValueEvent, useScroll, useSpring } from \"motion/react\";\n\ntype ScrollOffset = NonNullable<NonNullable<Parameters<typeof useScroll>[0]>[\"offset\"]>;\n\nexport type ScrollTextPathShape = \"wave\" | \"arc\" | \"loop\";\nexport type ScrollTextPathDirection = \"forward\" | \"reverse\";\n/** Which part of the line takes the accent colour. */\nexport type ScrollTextPathAccent = \"separator\" | \"text\" | \"alternate\";\n\nexport interface ScrollTextPathProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The phrase set along the path. Exposed once to assistive technology. */\n  text?: string;\n  /** Built-in path shape. Ignored when `d` is passed. */\n  path?: ScrollTextPathShape;\n  /** Custom SVG path data. Coordinates use `viewBox`. */\n  d?: string;\n  /** viewBox for a custom `d`. Built-in shapes use \"0 0 1000 360\". */\n  viewBox?: string;\n  /** Scrollable ancestor to track. Defaults to the window. */\n  container?: React.RefObject<HTMLElement | null>;\n  /** Scroll offsets, as in motion's useScroll: when the travel starts and ends. */\n  offset?: ScrollOffset;\n  /** Repeat the phrase so it fills the whole path. */\n  repeat?: boolean;\n  /** Inserted between repetitions. */\n  separator?: string;\n  /** Scrolling down moves the text along the path (forward) or against it (reverse). */\n  direction?: ScrollTextPathDirection;\n  /** Travel multiplier: how far the text moves per unit of scroll. */\n  speed?: number;\n  /** Font size in viewBox units (the type scales with the element's width). */\n  fontSize?: number;\n  /** Which part of the line is drawn in the accent colour. */\n  accentMode?: ScrollTextPathAccent;\n  /** Draw the path itself as a hairline guide. */\n  guide?: boolean;\n  /** Smooth scroll progress with a light spring. */\n  smooth?: boolean;\n  /** Accent colour (any CSS colour). Deepened automatically on light backgrounds. */\n  accent?: string;\n  /** Element to render. */\n  as?: \"div\" | \"p\" | \"h2\" | \"h3\";\n  /** Classes for the SVG text (font, weight, case). */\n  textClassName?: string;\n}\n\n/* SSR-safe: renders \"no preference\" on the server and the first client pass, so markup always hydrates. */\nfunction subscribeReducedMotion(onChange: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => mq.removeEventListener(\"change\", onChange);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nconst SHAPE_VIEWBOX = \"0 0 1000 360\";\nconst SHAPES: Record<ScrollTextPathShape, string> = {\n  // One full sine period: an arch, then a dip.\n  wave: \"M 0 190 C 150 50 350 50 500 190 S 850 330 1000 190\",\n  // A single wide arch.\n  arc: \"M 30 320 Q 500 -100 970 320\",\n  // Approach, curl up and over into a loop, cross underneath and leave.\n  loop: \"M 0 250 C 420 250 750 270 750 150 C 750 0 370 0 370 150 C 370 290 600 335 1000 335\",\n};\n\nconst DEFAULT_OFFSET: ScrollOffset = [\"start end\", \"end start\"];\n/** Rough glyph advance relative to font size, used until real measurements exist (server render). */\nconst GLYPH_ADVANCE = 0.55;\nconst MAX_COPIES = 60;\n\ninterface Metrics {\n  /** Path length in viewBox units. */\n  path: number;\n  /** Width of one `text + separator` unit. */\n  unit: number;\n  /** Width of the bare text. */\n  text: number;\n}\n\nconst round = (n: number) => Math.round(n * 100) / 100;\n\n/**\n * Text that travels along a curved SVG path as you scroll. The textPath's startOffset is\n * mapped from scroll progress (window or any scrollable container) and written straight to\n * the DOM, so scrolling never re-renders React.\n */\nexport function ScrollTextPath({\n  text = \"Scroll to steer the type\",\n  path = \"wave\",\n  d,\n  viewBox,\n  container,\n  offset = DEFAULT_OFFSET,\n  repeat = true,\n  separator = \" • \",\n  direction = \"forward\",\n  speed = 1,\n  fontSize = 44,\n  accentMode = \"separator\",\n  guide = true,\n  smooth = true,\n  accent = \"#FF7448\",\n  as = \"div\",\n  textClassName = \"font-semibold tracking-[-0.02em]\",\n  className = \"\",\n  style,\n  ...props\n}: ScrollTextPathProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement>(null);\n  const pathRef = React.useRef<SVGPathElement>(null);\n  const textPathRef = React.useRef<SVGTextPathElement>(null);\n  const unitProbeRef = React.useRef<SVGTSpanElement>(null);\n  const textProbeRef = React.useRef<SVGTSpanElement>(null);\n  const pathId = `stp-${React.useId().replace(/[^a-zA-Z0-9_-]/g, \"\")}`;\n\n  const pathData = d ?? SHAPES[path] ?? SHAPES.wave;\n  const box = d ? (viewBox ?? \"0 0 1000 360\") : SHAPE_VIEWBOX;\n\n  // Deterministic estimate for the server render; replaced by real measurements after mount.\n  const [metrics, setMetrics] = React.useState<Metrics>(() => ({\n    path: 1200,\n    unit: Math.max(1, Array.from(text + separator).length * fontSize * GLYPH_ADVANCE),\n    text: Math.max(1, Array.from(text).length * fontSize * GLYPH_ADVANCE),\n  }));\n\n  // Measure path and text lengths (in viewBox units, so they don't change on resize; only fonts matter).\n  React.useEffect(() => {\n    let cancelled = false;\n    const measure = () => {\n      if (cancelled) return;\n      const pathEl = pathRef.current;\n      const unitEl = unitProbeRef.current;\n      const textEl = textProbeRef.current;\n      if (!pathEl || !unitEl || !textEl) return;\n      const next: Metrics = {\n        path: round(pathEl.getTotalLength()),\n        unit: Math.max(1, round(unitEl.getComputedTextLength())),\n        text: Math.max(1, round(textEl.getComputedTextLength())),\n      };\n      setMetrics((prev) => (prev.path === next.path && prev.unit === next.unit && prev.text === next.text ? prev : next));\n    };\n    const frame = requestAnimationFrame(measure);\n    document.fonts?.ready.then(measure).catch(() => {});\n    return () => {\n      cancelled = true;\n      cancelAnimationFrame(frame);\n    };\n  }, [text, separator, fontSize, pathData, textClassName]);\n\n  const { scrollYProgress } = useScroll({ target: rootRef, container, offset });\n  const smoothed = useSpring(scrollYProgress, { stiffness: 140, damping: 28, mass: 0.4, restDelta: 0.0005 });\n  const progress = smooth ? smoothed : scrollYProgress;\n\n  const speedFactor = Math.max(0, speed);\n  // Repeat mode slides a long ribbon by half the path length (× speed); copies cover path + travel.\n  const travel = metrics.path * 0.5 * speedFactor;\n  const copies = repeat\n    ? Math.min(MAX_COPIES, Math.max(1, Math.ceil((metrics.path + travel) / metrics.unit) + 1))\n    : 1;\n\n  /** startOffset (viewBox units) for scroll progress `p`. */\n  const offsetAt = (p: number) => {\n    const t = Math.min(1, Math.max(0, p));\n    if (repeat) return direction === \"forward\" ? -travel + t * travel : -t * travel;\n    // A single phrase enters at one end and leaves at the other.\n    const span = (metrics.path + metrics.text) * speedFactor;\n    return direction === \"forward\" ? -metrics.text + t * span : metrics.path - t * span;\n  };\n  // Static placement for reduced motion and the server render.\n  const restingOffset = repeat ? 0 : Math.max(0, (metrics.path - metrics.text) / 2);\n\n  const apply = (p: number) => {\n    textPathRef.current?.setAttribute(\"startOffset\", String(round(offsetAt(p))));\n  };\n\n  React.useLayoutEffect(() => {\n    if (reduceMotion) textPathRef.current?.setAttribute(\"startOffset\", String(round(restingOffset)));\n    else apply(progress.get());\n  });\n\n  useMotionValueEvent(progress, \"change\", (p) => {\n    if (!reduceMotion) apply(p);\n  });\n\n  const Tag = as as React.ElementType;\n  const accentFill = \"var(--stp-ink)\";\n  const unitFill = (i: number) => {\n    if (accentMode === \"text\") return { text: accentFill, sep: undefined };\n    if (accentMode === \"alternate\") return { text: i % 2 === 1 ? accentFill : undefined, sep: undefined };\n    return { text: undefined, sep: accentFill };\n  };\n\n  return (\n    <Tag\n      ref={rootRef}\n      className={\n        // On light backgrounds the accent is deepened towards ink so accented words keep their contrast.\n        \"relative block w-full text-zinc-900 [--stp-ink:color-mix(in_oklab,var(--stp-accent)_78%,#18181b)] dark:text-zinc-100 dark:[--stp-ink:var(--stp-accent)] \" +\n        className\n      }\n      style={{ [\"--stp-accent\" as string]: accent, ...style }}\n      {...props}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <svg aria-hidden=\"true\" viewBox={box} className=\"block h-auto w-full overflow-hidden\" focusable=\"false\">\n        <defs>\n          <path id={pathId} ref={pathRef} d={pathData} />\n        </defs>\n        {guide ? (\n          // Drawn just below the baseline so it reads as a rule following the curve.\n          <use\n            href={`#${pathId}`}\n            transform={`translate(0 ${round(fontSize * 0.3)})`}\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeOpacity={0.35}\n            strokeWidth={1}\n            strokeLinecap=\"round\"\n            strokeDasharray=\"1 5\"\n            vectorEffect=\"non-scaling-stroke\"\n          />\n        ) : null}\n        {/* Invisible probes measure the real advance of one unit and of the bare text. */}\n        <text className={textClassName} style={{ fontSize, whiteSpace: \"pre\" }} visibility=\"hidden\">\n          <tspan ref={unitProbeRef}>{text + separator}</tspan>\n        </text>\n        <text className={textClassName} style={{ fontSize, whiteSpace: \"pre\" }} visibility=\"hidden\">\n          <tspan ref={textProbeRef}>{text}</tspan>\n        </text>\n        <text className={`fill-current ${textClassName}`} style={{ fontSize, whiteSpace: \"pre\" }}>\n          <textPath ref={textPathRef} href={`#${pathId}`} startOffset={round(restingOffset)}>\n            {repeat\n              ? Array.from({ length: copies }, (_, i) => {\n                  const fill = unitFill(i);\n                  return (\n                    <React.Fragment key={i}>\n                      <tspan fill={fill.text}>{text}</tspan>\n                      <tspan fill={fill.sep} fillOpacity={fill.sep ? 1 : 0.4}>\n                        {separator}\n                      </tspan>\n                    </React.Fragment>\n                  );\n                })\n              : (\n                <tspan fill={accentMode === \"separator\" ? undefined : accentFill}>{text}</tspan>\n              )}\n          </textPath>\n        </text>\n      </svg>\n    </Tag>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/scroll-text-path"
}
