{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stroke-trace",
  "title": "Stroke Trace",
  "description": "Draws along SVG strokes or an element's border, then holds, traces, loops, or orbits. Interruptible reverse or forward exits, with plain line or neon glow.",
  "files": [
    {
      "path": "registry/base/ui/stroke-trace.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  cloneElement,\n  Fragment,\n  isValidElement,\n  useCallback,\n  useLayoutEffect,\n  useRef,\n  useState,\n  type ComponentProps,\n  type CSSProperties,\n  type ReactElement,\n  type ReactNode,\n  type Ref,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nimport \"./stroke-trace.css\";\n\n/**\n * - `draw`: draws in when active and stays; retracts when deactivated.\n * - `trace`: draws in, then leaves forward, once per activation.\n * - `loop`: `trace` on repeat while active.\n * - `orbit`: a short segment circles the path while active.\n */\nexport type StrokeTraceMode = \"draw\" | \"trace\" | \"loop\" | \"orbit\";\n\ntype StrokeTraceOptions = {\n  mode?: StrokeTraceMode;\n  /** Runs the mode while true. Toggle it, or change `key`, to replay. */\n  active?: boolean;\n  /** Milliseconds for one draw, trace, loop cycle, or orbit lap. */\n  duration?: number;\n  /** Draw mode only. Exit direction; defaults to retracting along the same path. */\n  exitDirection?: \"reverse\" | \"forward\";\n  /** Draw mode only. Milliseconds for a full exit; defaults to 60% of duration.\n   * Partial exits take proportionally less time. */\n  exitDuration?: number;\n  /** `neon` renders a near-white core with a glow in `currentColor`. For dark surfaces. */\n  variant?: \"line\" | \"neon\";\n};\n\nexport type StrokeTraceProps = ComponentProps<\"svg\"> &\n  StrokeTraceOptions & {\n    /** Milliseconds between the start of each shape, in document order. */\n    stagger?: number;\n    /** Renders a faint copy of every shape under the trace. */\n    track?: boolean;\n  };\n\nexport type StrokeTraceBorderProps = Omit<ComponentProps<\"svg\">, \"children\"> &\n  StrokeTraceOptions & {\n    /** Uniform corner radius in px. Defaults to 0; clamped to half the box size. */\n    radius?: number;\n  };\n\nconst geometryTags = new Set([\n  \"path\",\n  \"circle\",\n  \"rect\",\n  \"ellipse\",\n  \"line\",\n  \"polyline\",\n  \"polygon\",\n]);\n\ntype ShapeProps = {\n  children?: ReactNode;\n  className?: string;\n  pathLength?: number | string;\n  style?: CSSProperties;\n};\n\nfunction msOr(value: number | undefined, fallback: number) {\n  return value !== undefined && Number.isFinite(value)\n    ? Math.max(0, value)\n    : fallback;\n}\n\n/** `off` is only reachable after `on`, so the first render never plays an exit. */\nfunction useTraceState(active: boolean) {\n  const [hasBeenActive, setHasBeenActive] = useState(active);\n\n  if (active && !hasBeenActive) {\n    setHasBeenActive(true);\n  }\n\n  return active ? \"on\" : hasBeenActive ? \"off\" : \"idle\";\n}\n\nfunction rootProps(\n  {\n    mode = \"draw\",\n    duration,\n    variant = \"line\",\n    exitDirection = \"reverse\",\n  }: StrokeTraceOptions,\n  state: string,\n  style: CSSProperties | undefined,\n) {\n  return {\n    \"aria-hidden\": true,\n    \"data-mode\": mode,\n    \"data-state\": state,\n    \"data-variant\": variant,\n    \"data-exit-direction\": mode === \"draw\" ? exitDirection : undefined,\n    style: {\n      \"--stroke-trace-duration\": `${msOr(duration, mode === \"orbit\" ? 1200 : 1000)}ms`,\n      ...style,\n    } as CSSProperties,\n  };\n}\n\n/** One node, two consumers; preserve React 19 callback-ref cleanup. */\nfunction useTraceNode(ref: Ref<SVGSVGElement> | undefined) {\n  const [node, setNode] = useState<SVGSVGElement | null>(null);\n  const setRefs = useCallback(\n    (element: SVGSVGElement | null) => {\n      setNode(element);\n      const cleanup = assignRef(ref, element);\n      return () => {\n        setNode(null);\n        cleanup();\n      };\n    },\n    [ref],\n  );\n  return [node, setRefs] as const;\n}\n\nfunction cssMs(value: string, fallback: number) {\n  const parsed = Number.parseFloat(value);\n  return Number.isFinite(parsed)\n    ? Math.max(0, parsed * (value.trim().endsWith(\"ms\") ? 1 : 1000))\n    : fallback;\n}\n\n/** Animate the visible segment's length and tail, without per-frame React state.\n * Snapshot before cancelling so every interruption starts at the current image. */\nfunction useDrawAnimation(\n  node: SVGSVGElement | null,\n  {\n    mode = \"draw\",\n    active = true,\n    duration,\n    exitDuration,\n    exitDirection = \"reverse\",\n  }: StrokeTraceOptions,\n  content: unknown,\n  track = false,\n  stagger = 0,\n) {\n  const managedShapes = useRef(new WeakSet<SVGElement>());\n  useLayoutEffect(() => {\n    if (!node) return;\n    const shapes = [\n      ...node.querySelectorAll<SVGElement>(\n        \"[pathLength]:not(.stroke-trace-track)\",\n      ),\n    ];\n    if (mode !== \"draw\") {\n      for (const shape of shapes) {\n        if (!managedShapes.current.has(shape)) continue;\n        shape.style.removeProperty(\"stroke-dasharray\");\n        shape.style.removeProperty(\"stroke-dashoffset\");\n        shape.style.removeProperty(\"opacity\");\n        managedShapes.current.delete(shape);\n      }\n      return;\n    }\n\n    const media = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n    let stop = () => {};\n    const play = () => {\n      stop();\n      const stops = shapes.map((shape) => {\n        managedShapes.current.add(shape);\n        const computed = getComputedStyle(shape);\n        const length = Math.min(\n          1,\n          Math.max(0, Number.parseFloat(computed.strokeDasharray) || 0),\n        );\n        const tail = -(Number.parseFloat(computed.strokeDashoffset) || 0);\n        const opacity = Number.parseFloat(computed.opacity) || 0;\n        const targetLength = active ? 1 : 0;\n        // Keep the segment inside [0, 1]. Re-entry expands the remaining segment\n        // back to both path endpoints, including after a partial forward exit.\n        const targetTail = active\n          ? 0\n          : exitDirection === \"forward\"\n            ? tail + length\n            : tail;\n        const fullDuration = cssMs(\n          computed.animationDuration,\n          msOr(duration, 1000),\n        );\n        const distance = Math.abs(targetLength - length);\n        const time = active\n          ? fullDuration\n          : msOr(exitDuration, fullDuration * 0.6);\n        const delay =\n          active && length === 0 && opacity === 0\n            ? cssMs(computed.animationDelay, 0)\n            : 0;\n        const target = {\n          // A period of 2 keeps the next dash beyond the path's endpoint,\n          // where round linecaps otherwise expose an extra dot while drawing.\n          strokeDasharray: `${targetLength} ${2 - targetLength}`,\n          strokeDashoffset: `${-targetTail}`,\n          opacity: active ? \"1\" : \"0\",\n        };\n        const finish = () => Object.assign(shape.style, target);\n        if (\n          (!active && length === 0 && opacity === 0) ||\n          (distance === 0 && opacity === Number(target.opacity)) ||\n          time === 0\n        ) {\n          finish();\n          return () => {};\n        }\n\n        const from = {\n          strokeDasharray: `${length} ${2 - length}`,\n          strokeDashoffset: `${-tail}`,\n          opacity: `${opacity}`,\n        };\n        const animation = shape.animate(\n          media.matches\n            ? [{ opacity: `${opacity}` }, { opacity: target.opacity }]\n            : [\n                from,\n                {\n                  opacity: active ? \"1\" : `${opacity}`,\n                  offset: active ? 0.15 : 0.85,\n                },\n                target,\n              ],\n          {\n            duration: media.matches ? Math.min(150, time) : time * distance,\n            delay: media.matches ? 0 : delay,\n            easing: \"cubic-bezier(0.23, 1, 0.32, 1)\",\n            fill: \"both\",\n          },\n        );\n        animation.onfinish = () => {\n          finish();\n          animation.cancel();\n        };\n        return () => {\n          const current = getComputedStyle(shape);\n          // Keep geometry underneath the reduced-motion CSS guard unchanged.\n          if (!media.matches) {\n            shape.style.strokeDasharray = current.strokeDasharray;\n            shape.style.strokeDashoffset = current.strokeDashoffset;\n          }\n          shape.style.opacity = current.opacity;\n          animation.onfinish = null;\n          animation.cancel();\n        };\n      });\n      stop = () => stops.forEach((cancel) => cancel());\n    };\n    play();\n    media.addEventListener(\"change\", play);\n    return () => {\n      media.removeEventListener(\"change\", play);\n      stop();\n    };\n  }, [\n    node,\n    mode,\n    active,\n    duration,\n    exitDuration,\n    exitDirection,\n    content,\n    track,\n    stagger,\n  ]);\n}\n\n/**\n * Overrides every SVG shape's pathLength with 1, so the same dash animation\n * fits any path without measuring it. Walks `<g>` and fragments; shapes inside\n * custom components are reached only if they set `pathLength={1}` themselves.\n */\nfunction prepareShapes(children: ReactNode, track: boolean) {\n  let index = 0;\n\n  const visit = (nodes: ReactNode): ReactNode =>\n    Children.map(nodes, (child) => {\n      if (!isValidElement<ShapeProps>(child)) {\n        return child;\n      }\n\n      if (child.type === Fragment || child.type === \"g\") {\n        return cloneElement(child, undefined, visit(child.props.children));\n      }\n\n      if (typeof child.type !== \"string\" || !geometryTags.has(child.type)) {\n        return child;\n      }\n\n      const shape = cloneElement(child as ReactElement<ShapeProps>, {\n        pathLength: 1,\n        style: {\n          \"--stroke-trace-index\": index++,\n          ...child.props.style,\n        } as CSSProperties,\n      });\n\n      if (!track) {\n        return shape;\n      }\n\n      return (\n        <>\n          {cloneElement(child as ReactElement<ShapeProps>, {\n            pathLength: undefined,\n            // Keep semantic selectors outside tailwind-merge: stroke-* is\n            // otherwise interpreted as a stroke-color utility.\n            className: `stroke-trace-track ${cn(child.props.className)}`,\n          })}\n          {shape}\n        </>\n      );\n    });\n\n  return visit(children);\n}\n\n/** Draws along the strokes of its SVG shapes. */\nexport function StrokeTrace({\n  mode,\n  active = true,\n  duration,\n  exitDuration,\n  exitDirection,\n  variant,\n  stagger = 0,\n  track = false,\n  className,\n  style,\n  children,\n  ref,\n  ...props\n}: StrokeTraceProps) {\n  const state = useTraceState(active);\n  const [node, setRefs] = useTraceNode(ref);\n  useDrawAnimation(\n    node,\n    { mode, active, duration, exitDuration, exitDirection },\n    children,\n    track,\n    stagger,\n  );\n\n  return (\n    <svg\n      {...rootProps({ mode, duration, variant, exitDirection }, state, {\n        \"--stroke-trace-stagger\": `${msOr(stagger, 0)}ms`,\n        ...style,\n      } as CSSProperties)}\n      {...props}\n      ref={setRefs}\n      data-slot=\"stroke-trace\"\n      className={`stroke-trace ${cn(className)}`}\n    >\n      {prepareShapes(children, track)}\n    </svg>\n  );\n}\n\ntype BorderBox = {\n  width: number;\n  height: number;\n  offset: number;\n};\n\nfunction readBorderBox(element: HTMLElement): BorderBox {\n  const computed = getComputedStyle(element);\n  // Layout size, unaffected by transforms such as a pressed-button scale.\n  const w = element.offsetWidth;\n  const h = element.offsetHeight;\n  return {\n    width: w,\n    height: h,\n    offset: Number.parseFloat(computed.borderTopWidth) || 0,\n  };\n}\n\n/**\n * Traces the border of its parent element. The parent must be positioned\n * (`relative`, `absolute`, …) with uniform border widths. Its size is followed\n * live; set `radius` explicitly to match its uniform corner radius in px.\n * Asymmetric borders and elliptical or per-corner radii are not supported.\n */\nexport function StrokeTraceBorder({\n  mode,\n  active = true,\n  duration,\n  exitDuration,\n  exitDirection,\n  variant,\n  radius = 0,\n  className,\n  style,\n  ref,\n  ...props\n}: StrokeTraceBorderProps) {\n  const state = useTraceState(active);\n  const [node, setRefs] = useTraceNode(ref);\n  const [box, setBox] = useState<BorderBox | null>(null);\n  useDrawAnimation(\n    node,\n    { mode, active, duration, exitDuration, exitDirection },\n    box,\n  );\n\n  useLayoutEffect(() => {\n    const parent = node?.parentElement;\n\n    if (!parent) {\n      return;\n    }\n\n    const update = () => {\n      const next = readBorderBox(parent);\n      setBox((current) =>\n        current &&\n        current.width === next.width &&\n        current.height === next.height &&\n        current.offset === next.offset\n          ? current\n          : next,\n      );\n    };\n\n    update();\n    const observer = new ResizeObserver(update);\n    observer.observe(parent, { box: \"border-box\" });\n\n    return () => observer.disconnect();\n  }, [node]);\n\n  return (\n    <svg\n      {...rootProps({ mode, duration, variant, exitDirection }, state, {\n        top: box ? -box.offset : 0,\n        left: box ? -box.offset : 0,\n        width: box?.width ?? 0,\n        height: box?.height ?? 0,\n        ...style,\n      })}\n      {...props}\n      ref={setRefs}\n      data-slot=\"stroke-trace-border\"\n      className={`stroke-trace stroke-trace-border ${cn(className)}`}\n    >\n      {box ? (\n        <rect\n          pathLength={1}\n          width={box.width}\n          height={box.height}\n          rx={Math.min(\n            Number.isFinite(radius) ? Math.max(0, radius) : 0,\n            box.width / 2,\n            box.height / 2,\n          )}\n          style={{ \"--stroke-trace-index\": 0 } as CSSProperties}\n        />\n      ) : null}\n    </svg>\n  );\n}\n\nfunction assignRef<T>(ref: Ref<T> | undefined, value: T | null) {\n  if (typeof ref === \"function\") {\n    const cleanup = ref(value);\n    return () => {\n      if (typeof cleanup === \"function\") {\n        cleanup();\n      } else {\n        ref(null);\n      }\n    };\n  }\n\n  if (ref) {\n    ref.current = value;\n  }\n\n  return () => {\n    if (ref) {\n      ref.current = null;\n    }\n  };\n}\n",
      "type": "registry:ui",
      "target": "@ui/stroke-trace.tsx"
    },
    {
      "path": "registry/base/ui/stroke-trace.css",
      "content": "/* Defaults are layered for customization. Normal unlayered styles and\n   utilities in a later layer can override them. */\n@layer components {\n  .stroke-trace {\n    --stroke-trace-glow: 12px;\n    --stroke-trace-segment: 0.25 0.75;\n    --stroke-trace-track-opacity: 0.15;\n    overflow: visible;\n    fill: none;\n    stroke: currentColor;\n    stroke-width: 2;\n    stroke-linecap: round;\n    stroke-linejoin: round;\n  }\n\n  .stroke-trace-border {\n    position: absolute;\n    pointer-events: none;\n  }\n\n  /* Shapes carry pathLength=\"1\", so one dash pattern fits every length. */\n  .stroke-trace [pathLength] {\n    stroke-dasharray: 1 1;\n    opacity: 0;\n    animation-duration: var(--stroke-trace-duration, 1000ms);\n    animation-timing-function: ease-in-out;\n    animation-delay: calc(\n      var(--stroke-trace-index, 0) * var(--stroke-trace-stagger, 0ms)\n    );\n    animation-fill-mode: both;\n  }\n\n  .stroke-trace-track {\n    opacity: var(--stroke-trace-track-opacity);\n  }\n\n  /* Draw is controlled by WAAPI so both exit directions can retarget smoothly. */\n  .stroke-trace[data-mode=\"draw\"] [pathLength] {\n    stroke-dasharray: 0 2;\n  }\n\n  .stroke-trace[data-mode=\"trace\"][data-state=\"on\"] [pathLength] {\n    animation-name: stroke-trace-trace;\n  }\n\n  .stroke-trace[data-mode=\"loop\"][data-state=\"on\"] [pathLength] {\n    animation-name: stroke-trace-trace;\n    animation-iteration-count: infinite;\n  }\n\n  .stroke-trace[data-mode=\"orbit\"][data-state=\"on\"] [pathLength] {\n    stroke-dasharray: var(--stroke-trace-segment);\n    opacity: 1;\n    animation-name: stroke-trace-orbit;\n    animation-timing-function: linear;\n    animation-iteration-count: infinite;\n  }\n\n  /* A near-white core with the colour moved into the glow, like a lit tube. */\n  .stroke-trace[data-variant=\"neon\"] {\n    stroke: color-mix(in oklab, currentColor 15%, white);\n  }\n\n  .stroke-trace[data-variant=\"neon\"] [pathLength] {\n    filter: drop-shadow(0 0 calc(var(--stroke-trace-glow) / 4) currentColor)\n      drop-shadow(0 0 var(--stroke-trace-glow) currentColor);\n  }\n\n  @keyframes stroke-trace-trace {\n    0% {\n      /* Keep the next dash beyond the endpoint while drawing, as in draw mode. */\n      stroke-dasharray: 0 2;\n      stroke-dashoffset: 0;\n      opacity: 0;\n    }\n    10% {\n      opacity: 1;\n    }\n    /* A short hold between drawing in and leaving. */\n    45%,\n    55% {\n      stroke-dasharray: 1 1;\n      stroke-dashoffset: 0;\n    }\n    90% {\n      opacity: 1;\n    }\n    100% {\n      stroke-dasharray: 1 1;\n      stroke-dashoffset: -1;\n      opacity: 0;\n    }\n  }\n\n  @keyframes stroke-trace-orbit {\n    from {\n      stroke-dashoffset: 0;\n    }\n    to {\n      stroke-dashoffset: -1;\n    }\n  }\n\n  @keyframes stroke-trace-pulse {\n    0%,\n    100% {\n      opacity: 0.35;\n    }\n    50% {\n      opacity: 1;\n    }\n  }\n}\n\n/* Motion guards override ordinary customization, including inline timing.\n   Important declarations also beat keyframes, so every mode keeps only its\n   opacity change: draw fades, trace and loop pulse in place. */\n@media (prefers-reduced-motion: reduce) {\n  .stroke-trace [pathLength] {\n    stroke-dasharray: none !important;\n    stroke-dashoffset: 0 !important;\n  }\n\n  .stroke-trace[data-mode=\"orbit\"][data-state=\"on\"] [pathLength] {\n    animation-name: stroke-trace-pulse !important;\n    animation-timing-function: ease-in-out !important;\n  }\n}\n",
      "type": "registry:file",
      "target": "@ui/stroke-trace.css"
    }
  ],
  "meta": {
    "tags": [
      "svg",
      "stroke",
      "loader",
      "focus",
      "neon"
    ],
    "effects": [
      "line-draw",
      "glow"
    ]
  },
  "categories": [
    "motion-primitive"
  ],
  "type": "registry:ui"
}