{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "runnel",
  "type": "registry:ui",
  "title": "Runnel",
  "description": "A vertical tracing beam that draws itself along your content as the user scrolls, its white-to-blue-violet stroke tipped with a glowing white head.",
  "dependencies": [
    "gsap",
    "@gsap/react"
  ],
  "registryDependencies": [
    "https://crucibleui.com/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/default/scroll/runnel/runnel.tsx",
      "type": "registry:ui",
      "target": "components/crucible/runnel.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport gsap from \"gsap\";\nimport { ScrollTrigger } from \"gsap/ScrollTrigger\";\nimport { useGSAP } from \"@gsap/react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nif (typeof window !== \"undefined\") {\n  gsap.registerPlugin(ScrollTrigger, useGSAP);\n}\n\nexport interface RunnelProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Content the beam runs alongside. Its height (via ResizeObserver) drives the SVG's length. */\n  children: React.ReactNode;\n  /** Which edge of the container the beam hugs. @default \"left\" */\n  side?: \"left\" | \"right\";\n  /** Gradient stops for the stroke, tail to head. @default [\"#ffffff\", \"#818cf8\"] (white -> blue-violet) */\n  colors?: [string, string];\n  /** ScrollTrigger `start`, relative to the wrapper entering the scroller's viewport. @default \"top 80%\" */\n  start?: string;\n  /** ScrollTrigger `end`, relative to the wrapper leaving the scroller's viewport. @default \"bottom 60%\" */\n  end?: string;\n  /**\n   * The element ScrollTrigger measures scroll progress against. Defaults to the window.\n   * Pass a ref or element to drive the beam from an internal `overflow-y-auto` container instead.\n   */\n  scroller?: string | Element | React.RefObject<HTMLElement | null>;\n  /** Stroke width in px. @default 2 */\n  strokeWidth?: number;\n  /** Width in px reserved for the SVG lane, and the padding applied to push content off the edge. @default 28 */\n  inset?: number;\n}\n\nconst DEFAULT_COLORS: [string, string] = [\"#ffffff\", \"#818cf8\"];\n\n/** Deterministic, gently wavy vertical path — no randomness, so server/client markup always match. */\nfunction buildPathD(height: number, width: number): string {\n  if (height <= 0) return \"\";\n\n  const segments = Math.max(3, Math.round(height / 220));\n  const cx = width / 2;\n  const amplitude = Math.min(width / 2 - 3, 6);\n  const points: { x: number; y: number }[] = [];\n\n  for (let i = 0; i <= segments; i++) {\n    const t = i / segments;\n    points.push({\n      x: cx + Math.sin(t * Math.PI * 2.2 + 0.6) * amplitude,\n      y: t * height,\n    });\n  }\n\n  let d = `M ${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`;\n  for (let i = 0; i < points.length - 1; i++) {\n    const p0 = points[i - 1] ?? points[i];\n    const p1 = points[i];\n    const p2 = points[i + 1];\n    const p3 = points[i + 2] ?? p2;\n    const c1x = p1.x + (p2.x - p0.x) / 6;\n    const c1y = p1.y + (p2.y - p0.y) / 6;\n    const c2x = p2.x - (p3.x - p1.x) / 6;\n    const c2y = p2.y - (p3.y - p1.y) / 6;\n    d += ` C ${c1x.toFixed(2)} ${c1y.toFixed(2)}, ${c2x.toFixed(2)} ${c2y.toFixed(2)}, ${p2.x.toFixed(2)} ${p2.y.toFixed(2)}`;\n  }\n\n  return d;\n}\n\nfunction resolveScroller(\n  scroller: RunnelProps[\"scroller\"]\n): string | Element | undefined {\n  if (!scroller) return undefined;\n  if (typeof scroller === \"string\") return scroller;\n  if (scroller instanceof Element) return scroller;\n  return scroller.current ?? undefined;\n}\n\n/**\n * Runnel — a vertical tracing beam that draws itself along your content as the\n * user scrolls: a glowing white-to-blue-violet stroke tipped with a bright\n * haloed head bead that flares while you actively scroll and settles as you\n * stop, easing into place with a smooth catch-up. Wrap any long-form content\n * (article, timeline, changelog) and the line measures itself to match.\n *\n * SSR-safe: the path is deterministic and renders empty until content height is\n * measured client-side. Honors prefers-reduced-motion by rendering the line\n * fully drawn and static, with no scroll-driven animation.\n */\nexport function Runnel({\n  children,\n  className,\n  side = \"left\",\n  colors = DEFAULT_COLORS,\n  start = \"top 80%\",\n  end = \"bottom 60%\",\n  scroller,\n  strokeWidth = 2,\n  inset = 28,\n  style,\n  ...props\n}: RunnelProps) {\n  const wrapperRef = React.useRef<HTMLDivElement>(null);\n  const contentRef = React.useRef<HTMLDivElement>(null);\n  const pathRef = React.useRef<SVGPathElement>(null);\n  const glowPathRef = React.useRef<SVGPathElement>(null);\n  const headHaloRef = React.useRef<SVGCircleElement>(null);\n  const headGlowRef = React.useRef<SVGCircleElement>(null);\n  const headRef = React.useRef<SVGCircleElement>(null);\n\n  const [contentHeight, setContentHeight] = React.useState(0);\n  const prefersReducedMotion = useReducedMotion();\n  const reactId = React.useId();\n  const gradientId = `runnel-gradient-${reactId}`;\n  const glowId = `runnel-glow-${reactId}`;\n  const lineGlowId = `runnel-line-glow-${reactId}`;\n\n  const pathD = React.useMemo(\n    () => buildPathD(contentHeight, inset),\n    [contentHeight, inset]\n  );\n\n  React.useEffect(() => {\n    const node = contentRef.current;\n    if (!node) return;\n\n    setContentHeight(node.scrollHeight);\n\n    const observer = new ResizeObserver((entries) => {\n      const entry = entries[0];\n      setContentHeight(entry?.contentRect.height ?? node.scrollHeight);\n    });\n    observer.observe(node);\n\n    return () => observer.disconnect();\n  }, []);\n\n  useGSAP(\n    () => {\n      const path = pathRef.current;\n      const glowPath = glowPathRef.current;\n      if (!path || contentHeight <= 0) return;\n\n      const length = path.getTotalLength();\n      const strokes = glowPath ? [path, glowPath] : [path];\n      const halo = headHaloRef.current;\n      const glow = headGlowRef.current;\n      const head = headRef.current;\n\n      if (prefersReducedMotion) {\n        gsap.set(strokes, { strokeDasharray: length, strokeDashoffset: 0 });\n        const tip = path.getPointAtLength(length);\n        if (halo) gsap.set(halo, { attr: { cx: tip.x, cy: tip.y }, opacity: 0.25 });\n        if (glow) gsap.set(glow, { attr: { cx: tip.x, cy: tip.y }, opacity: 0.9 });\n        if (head) gsap.set(head, { attr: { cx: tip.x, cy: tip.y }, opacity: 1 });\n        return;\n      }\n\n      gsap.set(strokes, { strokeDasharray: length, strokeDashoffset: length });\n      const startPoint = path.getPointAtLength(0);\n\n      // Head painter: position + intensity in one place. `boost` rises\n      // instantly with scroll velocity and decays smoothly once scrolling\n      // stops, so the head visibly flares while the user is in motion.\n      let headX = startPoint.x;\n      let headY = startPoint.y;\n      let headVisible = 0;\n      const boost = { value: 0 };\n      const baseGlowR = strokeWidth * 2.8;\n      const baseHaloR = strokeWidth * 5.5;\n\n      const paintHead = () => {\n        if (halo)\n          gsap.set(halo, {\n            attr: { cx: headX, cy: headY, r: baseHaloR * (1 + boost.value * 1.1) },\n            opacity: headVisible * (0.18 + boost.value * 0.55),\n          });\n        if (glow)\n          gsap.set(glow, {\n            attr: { cx: headX, cy: headY, r: baseGlowR * (1 + boost.value * 0.8) },\n            opacity: headVisible * (0.6 + boost.value * 0.4),\n          });\n        if (head) gsap.set(head, { attr: { cx: headX, cy: headY }, opacity: headVisible });\n      };\n      paintHead();\n\n      const tween = gsap.to(strokes, {\n        strokeDashoffset: 0,\n        ease: \"none\",\n        scrollTrigger: {\n          trigger: wrapperRef.current,\n          scroller: resolveScroller(scroller),\n          start,\n          end,\n          // Numeric scrub = smooth eased catch-up instead of a hard 1:1 lock.\n          scrub: 0.75,\n          invalidateOnRefresh: true,\n          onUpdate(self) {\n            const velocity = Math.min(Math.abs(self.getVelocity()) / 1200, 1);\n            if (velocity > boost.value) boost.value = velocity;\n            gsap.to(boost, {\n              value: 0,\n              duration: 0.8,\n              ease: \"power2.out\",\n              overwrite: true,\n              onUpdate: paintHead,\n            });\n            paintHead();\n          },\n        },\n        onUpdate() {\n          const offset = Number(gsap.getProperty(path, \"strokeDashoffset\"));\n          const drawn = Math.max(0, Math.min(length, length - offset));\n          const point = path.getPointAtLength(drawn);\n          headX = point.x;\n          headY = point.y;\n          headVisible = drawn > 0.5 ? 1 : 0;\n          paintHead();\n        },\n      });\n\n      return () => {\n        gsap.killTweensOf(boost);\n        tween.scrollTrigger?.kill();\n        tween.kill();\n      };\n    },\n    {\n      scope: wrapperRef,\n      dependencies: [contentHeight, side, start, end, strokeWidth, inset, prefersReducedMotion, scroller],\n      revertOnUpdate: true,\n    }\n  );\n\n  return (\n    <div\n      ref={wrapperRef}\n      data-crucible=\"runnel\"\n      className={cn(\"relative\", className)}\n      style={style}\n      {...props}\n    >\n      <svg\n        aria-hidden\n        focusable=\"false\"\n        className={cn(\n          \"pointer-events-none absolute top-0 overflow-visible\",\n          side === \"right\" ? \"right-0\" : \"left-0\"\n        )}\n        width={inset}\n        height={contentHeight}\n        viewBox={`0 0 ${inset} ${Math.max(contentHeight, 1)}`}\n        preserveAspectRatio=\"none\"\n      >\n        <defs>\n          <linearGradient id={gradientId} x1=\"0\" y1=\"0\" x2=\"0\" y2=\"1\">\n            <stop offset=\"0%\" stopColor={colors[0]} />\n            <stop offset=\"100%\" stopColor={colors[1]} />\n          </linearGradient>\n          <filter id={glowId} x=\"-300%\" y=\"-300%\" width=\"700%\" height=\"700%\">\n            <feGaussianBlur stdDeviation={strokeWidth * 2.2} result=\"blur\" />\n          </filter>\n          <filter id={lineGlowId} x=\"-400%\" y=\"-5%\" width=\"900%\" height=\"110%\">\n            <feGaussianBlur stdDeviation={strokeWidth * 1.5} result=\"blur\" />\n          </filter>\n        </defs>\n        {/* Soft glow under-stroke — a blurred, wider copy of the traced line. */}\n        <path\n          ref={glowPathRef}\n          d={pathD}\n          fill=\"none\"\n          stroke={`url(#${gradientId})`}\n          strokeWidth={strokeWidth * 2.6}\n          strokeOpacity={0.55}\n          strokeLinecap=\"round\"\n          filter={`url(#${lineGlowId})`}\n        />\n        <path\n          ref={pathRef}\n          d={pathD}\n          fill=\"none\"\n          stroke={`url(#${gradientId})`}\n          strokeWidth={strokeWidth}\n          strokeLinecap=\"round\"\n        />\n        {/* Head bead: wide halo (flares with scroll velocity) + glow + white core. */}\n        <circle ref={headHaloRef} cx={0} cy={0} r={strokeWidth * 5.5} fill={colors[1]} filter={`url(#${glowId})`} opacity={0} />\n        <circle ref={headGlowRef} cx={0} cy={0} r={strokeWidth * 2.8} fill={colors[1]} filter={`url(#${glowId})`} opacity={0} />\n        <circle ref={headRef} cx={0} cy={0} r={strokeWidth * 1.2} fill=\"#fff\" opacity={0} />\n      </svg>\n      <div\n        ref={contentRef}\n        style={{ [side === \"right\" ? \"paddingRight\" : \"paddingLeft\"]: inset }}\n      >\n        {children}\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}