{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"An SVG path that draws itself as you scroll, optionally with a marker riding the tip.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  type MotionValue,\n  motion,\n  useMotionValue,\n  useMotionValueEvent,\n  useReducedMotion,\n  useScroll,\n  useSpring,\n  useTransform,\n} from \"motion/react\";\nimport {\n  type ReactNode,\n  type RefObject,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n} from \"react\";\n\n/** Motion scroll offset tuple, typed from `useScroll` itself. */\nexport type ScrollOffset = NonNullable<\n  Parameters<typeof useScroll>[0]\n>[\"offset\"];\n\nconst SPRING_CONFIG = { damping: 28, mass: 0.4, stiffness: 240 } as const;\n// Finishes drawing while the artwork is still mid-viewport rather than on its\n// way out, so the completed stroke is something you actually get to look at.\nconst DEFAULT_OFFSET: ScrollOffset = [\"start 0.9\", \"end 0.6\"];\nconst DEFAULT_VIEW_BOX = \"0 0 100 100\";\nconst DEFAULT_STROKE_WIDTH = 2;\nconst DEFAULT_STROKE = \"currentColor\";\nconst MARKER_KEY_SLICE = 8;\nconst FULL_PROGRESS = 1;\n\nexport interface SvgDrawOnScrollProps {\n  className?: string;\n  /** Ref to a scrollable ancestor that drives progress instead of the window. */\n  container?: RefObject<HTMLElement | null>;\n  /** Rides the tip of the drawn stroke, positioned from the path's own geometry. */\n  marker?: ReactNode;\n  /** Motion `useScroll` offset tuple, e.g. [\"start 0.9\", \"end 0.3\"]. */\n  offset?: ScrollOffset;\n  /** Freeze fully drawn once complete instead of undrawing on scroll-up. */\n  once?: boolean;\n  /** Single path's `d`. Ignored if `paths` is also provided. */\n  path?: string;\n  /** Multiple paths, drawn one after another across the scroll range. */\n  paths?: string[];\n  stroke?: string;\n  strokeWidth?: number;\n  viewBox?: string;\n}\n\ninterface DrawPathProps {\n  d: string;\n  progress: MotionValue<number>;\n  range: [number, number];\n  stroke: string;\n  strokeWidth: number;\n}\n\nconst clampUnit = (value: number) => Math.min(1, Math.max(0, value));\n\nconst DrawPath = ({\n  d,\n  progress,\n  range,\n  stroke,\n  strokeWidth,\n}: DrawPathProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  const pathLength = useTransform(\n    progress,\n    range,\n    shouldReduceMotion ? [1, 1] : [0, 1]\n  );\n\n  return (\n    <motion.path\n      d={d}\n      fill=\"none\"\n      pathLength={1}\n      stroke={stroke}\n      strokeLinecap=\"round\"\n      strokeWidth={strokeWidth}\n      style={{ pathLength }}\n    />\n  );\n};\n\nexport default function SvgDrawOnScroll({\n  path,\n  paths,\n  viewBox = DEFAULT_VIEW_BOX,\n  strokeWidth = DEFAULT_STROKE_WIDTH,\n  stroke = DEFAULT_STROKE,\n  marker,\n  offset = DEFAULT_OFFSET,\n  container,\n  once = false,\n  className,\n}: SvgDrawOnScrollProps) {\n  const ref = useRef<HTMLDivElement>(null);\n  const measureRef = useRef<SVGPathElement>(null);\n  const shouldReduceMotion = useReducedMotion();\n  const maxProgressRef = useRef(0);\n\n  const pathList = useMemo(\n    () => (paths && paths.length > 0 ? paths : path ? [path] : []),\n    [paths, path]\n  );\n  const combinedD = useMemo(() => pathList.join(\" \"), [pathList]);\n\n  const { scrollYProgress } = useScroll({ container, offset, target: ref });\n\n  const monotonicProgress = useTransform(scrollYProgress, (latest) => {\n    if (!once) {\n      return latest;\n    }\n    maxProgressRef.current = Math.max(maxProgressRef.current, latest);\n    return maxProgressRef.current;\n  });\n  const spring = useSpring(monotonicProgress, SPRING_CONFIG);\n\n  // The marker is positioned from the path's own geometry rather than CSS\n  // `offset-path`. `offset-path: path()` measures in the element's CSS pixel\n  // space, but the `d` is written in viewBox units — so any viewBox that is not\n  // 1:1 with the rendered box (which is the normal case, since the SVG scales\n  // with `preserveAspectRatio`) left the marker floating away from the stroke\n  // it is supposed to ride. `getPointAtLength` + `getScreenCTM` resolve the\n  // point through the exact same transform the browser used to paint the line.\n  const markerX = useMotionValue(0);\n  const markerY = useMotionValue(0);\n\n  const syncMarker = useCallback(\n    (latest: number) => {\n      const pathElement = measureRef.current;\n      const wrapper = ref.current;\n\n      if (!(pathElement && wrapper)) {\n        return;\n      }\n\n      const totalLength = pathElement.getTotalLength();\n      const matrix = pathElement.getScreenCTM();\n\n      if (!(totalLength && matrix)) {\n        return;\n      }\n\n      const progress = shouldReduceMotion ? FULL_PROGRESS : clampUnit(latest);\n      const point = pathElement.getPointAtLength(progress * totalLength);\n      const screenPoint = new DOMPoint(point.x, point.y).matrixTransform(\n        matrix\n      );\n      const rect = wrapper.getBoundingClientRect();\n\n      markerX.set(screenPoint.x - rect.left);\n      markerY.set(screenPoint.y - rect.top);\n    },\n    [markerX, markerY, shouldReduceMotion]\n  );\n\n  useMotionValueEvent(spring, \"change\", syncMarker);\n\n  // Also sync on mount and on resize: the scroll value can sit still for the\n  // whole life of the component (reduced motion, or simply an untouched page)\n  // and the mapping changes whenever the SVG is laid out at a new size.\n  useEffect(() => {\n    const wrapper = ref.current;\n\n    if (!(marker && combinedD && wrapper)) {\n      return;\n    }\n\n    syncMarker(spring.get());\n\n    const observer = new ResizeObserver(() => syncMarker(spring.get()));\n    observer.observe(wrapper);\n\n    return () => observer.disconnect();\n  }, [marker, combinedD, spring, syncMarker]);\n\n  return (\n    <div className={cn(\"relative\", className)} ref={ref}>\n      <svg\n        aria-hidden=\"true\"\n        className=\"h-full w-full\"\n        fill=\"none\"\n        preserveAspectRatio=\"xMidYMid meet\"\n        viewBox={viewBox}\n        xmlns=\"http://www.w3.org/2000/svg\"\n      >\n        {pathList.map((d, index) => (\n          <DrawPath\n            d={d}\n            key={`svg-draw-on-scroll-path-${index}-${d.slice(0, MARKER_KEY_SLICE)}`}\n            progress={spring}\n            range={[index / pathList.length, (index + 1) / pathList.length]}\n            stroke={stroke}\n            strokeWidth={strokeWidth}\n          />\n        ))}\n        {/* Never painted — it exists only so the marker has a single, stable\n            geometry to measure against, including across multiple paths. */}\n        {marker && combinedD ? (\n          <path d={combinedD} fill=\"none\" ref={measureRef} stroke=\"none\" />\n        ) : null}\n      </svg>\n      {marker && combinedD ? (\n        <motion.div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute top-0 left-0\"\n          style={{ x: markerX, y: markerY }}\n        >\n          <div className=\"-translate-x-1/2 -translate-y-1/2\">{marker}</div>\n        </motion.div>\n      ) : null}\n    </div>\n  );\n}\n","path":"index.tsx","target":"components/smoothui/svg-draw-on-scroll/index.tsx","type":"registry:ui"}],"name":"svg-draw-on-scroll","registryDependencies":[],"title":"Svg Draw On Scroll","type":"registry:ui"}