Back to components

Stroke Trace

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.

motion-primitiveline-draw
Plain line
Neon glow
"use client";

import {
  Children,
  cloneElement,
  Fragment,
  isValidElement,
  useCallback,
  useLayoutEffect,
  useRef,
  useState,
  type ComponentProps,
  type CSSProperties,
  type ReactElement,
  type ReactNode,
  type Ref,
} from "react";

import { cn } from "@/lib/utils";

import "./stroke-trace.css";

/**
 * - `draw`: draws in when active and stays; retracts when deactivated.
 * - `trace`: draws in, then leaves forward, once per activation.
 * - `loop`: `trace` on repeat while active.
 * - `orbit`: a short segment circles the path while active.
 */
export type StrokeTraceMode = "draw" | "trace" | "loop" | "orbit";

type StrokeTraceOptions = {
  mode?: StrokeTraceMode;
  /** Runs the mode while true. Toggle it, or change `key`, to replay. */
  active?: boolean;
  /** Milliseconds for one draw, trace, loop cycle, or orbit lap. */
  duration?: number;
  /** Draw mode only. Exit direction; defaults to retracting along the same path. */
  exitDirection?: "reverse" | "forward";
  /** Draw mode only. Milliseconds for a full exit; defaults to 60% of duration.
   * Partial exits take proportionally less time. */
  exitDuration?: number;
  /** `neon` renders a near-white core with a glow in `currentColor`. For dark surfaces. */
  variant?: "line" | "neon";
};

export type StrokeTraceProps = ComponentProps<"svg"> &
  StrokeTraceOptions & {
    /** Milliseconds between the start of each shape, in document order. */
    stagger?: number;
    /** Renders a faint copy of every shape under the trace. */
    track?: boolean;
  };

export type StrokeTraceBorderProps = Omit<ComponentProps<"svg">, "children"> &
  StrokeTraceOptions & {
    /** Uniform corner radius in px. Defaults to 0; clamped to half the box size. */
    radius?: number;
  };

const geometryTags = new Set([
  "path",
  "circle",
  "rect",
  "ellipse",
  "line",
  "polyline",
  "polygon",
]);

type ShapeProps = {
  children?: ReactNode;
  className?: string;
  pathLength?: number | string;
  style?: CSSProperties;
};

function msOr(value: number | undefined, fallback: number) {
  return value !== undefined && Number.isFinite(value)
    ? Math.max(0, value)
    : fallback;
}

/** `off` is only reachable after `on`, so the first render never plays an exit. */
function useTraceState(active: boolean) {
  const [hasBeenActive, setHasBeenActive] = useState(active);

  if (active && !hasBeenActive) {
    setHasBeenActive(true);
  }

  return active ? "on" : hasBeenActive ? "off" : "idle";
}

function rootProps(
  {
    mode = "draw",
    duration,
    variant = "line",
    exitDirection = "reverse",
  }: StrokeTraceOptions,
  state: string,
  style: CSSProperties | undefined,
) {
  return {
    "aria-hidden": true,
    "data-mode": mode,
    "data-state": state,
    "data-variant": variant,
    "data-exit-direction": mode === "draw" ? exitDirection : undefined,
    style: {
      "--stroke-trace-duration": `${msOr(duration, mode === "orbit" ? 1200 : 1000)}ms`,
      ...style,
    } as CSSProperties,
  };
}

/** One node, two consumers; preserve React 19 callback-ref cleanup. */
function useTraceNode(ref: Ref<SVGSVGElement> | undefined) {
  const [node, setNode] = useState<SVGSVGElement | null>(null);
  const setRefs = useCallback(
    (element: SVGSVGElement | null) => {
      setNode(element);
      const cleanup = assignRef(ref, element);
      return () => {
        setNode(null);
        cleanup();
      };
    },
    [ref],
  );
  return [node, setRefs] as const;
}

function cssMs(value: string, fallback: number) {
  const parsed = Number.parseFloat(value);
  return Number.isFinite(parsed)
    ? Math.max(0, parsed * (value.trim().endsWith("ms") ? 1 : 1000))
    : fallback;
}

/** Animate the visible segment's length and tail, without per-frame React state.
 * Snapshot before cancelling so every interruption starts at the current image. */
function useDrawAnimation(
  node: SVGSVGElement | null,
  {
    mode = "draw",
    active = true,
    duration,
    exitDuration,
    exitDirection = "reverse",
  }: StrokeTraceOptions,
  content: unknown,
  track = false,
  stagger = 0,
) {
  const managedShapes = useRef(new WeakSet<SVGElement>());
  useLayoutEffect(() => {
    if (!node) return;
    const shapes = [
      ...node.querySelectorAll<SVGElement>(
        "[pathLength]:not(.stroke-trace-track)",
      ),
    ];
    if (mode !== "draw") {
      for (const shape of shapes) {
        if (!managedShapes.current.has(shape)) continue;
        shape.style.removeProperty("stroke-dasharray");
        shape.style.removeProperty("stroke-dashoffset");
        shape.style.removeProperty("opacity");
        managedShapes.current.delete(shape);
      }
      return;
    }

    const media = window.matchMedia("(prefers-reduced-motion: reduce)");
    let stop = () => {};
    const play = () => {
      stop();
      const stops = shapes.map((shape) => {
        managedShapes.current.add(shape);
        const computed = getComputedStyle(shape);
        const length = Math.min(
          1,
          Math.max(0, Number.parseFloat(computed.strokeDasharray) || 0),
        );
        const tail = -(Number.parseFloat(computed.strokeDashoffset) || 0);
        const opacity = Number.parseFloat(computed.opacity) || 0;
        const targetLength = active ? 1 : 0;
        // Keep the segment inside [0, 1]. Re-entry expands the remaining segment
        // back to both path endpoints, including after a partial forward exit.
        const targetTail = active
          ? 0
          : exitDirection === "forward"
            ? tail + length
            : tail;
        const fullDuration = cssMs(
          computed.animationDuration,
          msOr(duration, 1000),
        );
        const distance = Math.abs(targetLength - length);
        const time = active
          ? fullDuration
          : msOr(exitDuration, fullDuration * 0.6);
        const delay =
          active && length === 0 && opacity === 0
            ? cssMs(computed.animationDelay, 0)
            : 0;
        const target = {
          // A period of 2 keeps the next dash beyond the path's endpoint,
          // where round linecaps otherwise expose an extra dot while drawing.
          strokeDasharray: `${targetLength} ${2 - targetLength}`,
          strokeDashoffset: `${-targetTail}`,
          opacity: active ? "1" : "0",
        };
        const finish = () => Object.assign(shape.style, target);
        if (
          (!active && length === 0 && opacity === 0) ||
          (distance === 0 && opacity === Number(target.opacity)) ||
          time === 0
        ) {
          finish();
          return () => {};
        }

        const from = {
          strokeDasharray: `${length} ${2 - length}`,
          strokeDashoffset: `${-tail}`,
          opacity: `${opacity}`,
        };
        const animation = shape.animate(
          media.matches
            ? [{ opacity: `${opacity}` }, { opacity: target.opacity }]
            : [
                from,
                {
                  opacity: active ? "1" : `${opacity}`,
                  offset: active ? 0.15 : 0.85,
                },
                target,
              ],
          {
            duration: media.matches ? Math.min(150, time) : time * distance,
            delay: media.matches ? 0 : delay,
            easing: "cubic-bezier(0.23, 1, 0.32, 1)",
            fill: "both",
          },
        );
        animation.onfinish = () => {
          finish();
          animation.cancel();
        };
        return () => {
          const current = getComputedStyle(shape);
          // Keep geometry underneath the reduced-motion CSS guard unchanged.
          if (!media.matches) {
            shape.style.strokeDasharray = current.strokeDasharray;
            shape.style.strokeDashoffset = current.strokeDashoffset;
          }
          shape.style.opacity = current.opacity;
          animation.onfinish = null;
          animation.cancel();
        };
      });
      stop = () => stops.forEach((cancel) => cancel());
    };
    play();
    media.addEventListener("change", play);
    return () => {
      media.removeEventListener("change", play);
      stop();
    };
  }, [
    node,
    mode,
    active,
    duration,
    exitDuration,
    exitDirection,
    content,
    track,
    stagger,
  ]);
}

/**
 * Overrides every SVG shape's pathLength with 1, so the same dash animation
 * fits any path without measuring it. Walks `<g>` and fragments; shapes inside
 * custom components are reached only if they set `pathLength={1}` themselves.
 */
function prepareShapes(children: ReactNode, track: boolean) {
  let index = 0;

  const visit = (nodes: ReactNode): ReactNode =>
    Children.map(nodes, (child) => {
      if (!isValidElement<ShapeProps>(child)) {
        return child;
      }

      if (child.type === Fragment || child.type === "g") {
        return cloneElement(child, undefined, visit(child.props.children));
      }

      if (typeof child.type !== "string" || !geometryTags.has(child.type)) {
        return child;
      }

      const shape = cloneElement(child as ReactElement<ShapeProps>, {
        pathLength: 1,
        style: {
          "--stroke-trace-index": index++,
          ...child.props.style,
        } as CSSProperties,
      });

      if (!track) {
        return shape;
      }

      return (
        <>
          {cloneElement(child as ReactElement<ShapeProps>, {
            pathLength: undefined,
            // Keep semantic selectors outside tailwind-merge: stroke-* is
            // otherwise interpreted as a stroke-color utility.
            className: `stroke-trace-track ${cn(child.props.className)}`,
          })}
          {shape}
        </>
      );
    });

  return visit(children);
}

/** Draws along the strokes of its SVG shapes. */
export function StrokeTrace({
  mode,
  active = true,
  duration,
  exitDuration,
  exitDirection,
  variant,
  stagger = 0,
  track = false,
  className,
  style,
  children,
  ref,
  ...props
}: StrokeTraceProps) {
  const state = useTraceState(active);
  const [node, setRefs] = useTraceNode(ref);
  useDrawAnimation(
    node,
    { mode, active, duration, exitDuration, exitDirection },
    children,
    track,
    stagger,
  );

  return (
    <svg
      {...rootProps({ mode, duration, variant, exitDirection }, state, {
        "--stroke-trace-stagger": `${msOr(stagger, 0)}ms`,
        ...style,
      } as CSSProperties)}
      {...props}
      ref={setRefs}
      data-slot="stroke-trace"
      className={`stroke-trace ${cn(className)}`}
    >
      {prepareShapes(children, track)}
    </svg>
  );
}

type BorderBox = {
  width: number;
  height: number;
  offset: number;
};

function readBorderBox(element: HTMLElement): BorderBox {
  const computed = getComputedStyle(element);
  // Layout size, unaffected by transforms such as a pressed-button scale.
  const w = element.offsetWidth;
  const h = element.offsetHeight;
  return {
    width: w,
    height: h,
    offset: Number.parseFloat(computed.borderTopWidth) || 0,
  };
}

/**
 * Traces the border of its parent element. The parent must be positioned
 * (`relative`, `absolute`, …) with uniform border widths. Its size is followed
 * live; set `radius` explicitly to match its uniform corner radius in px.
 * Asymmetric borders and elliptical or per-corner radii are not supported.
 */
export function StrokeTraceBorder({
  mode,
  active = true,
  duration,
  exitDuration,
  exitDirection,
  variant,
  radius = 0,
  className,
  style,
  ref,
  ...props
}: StrokeTraceBorderProps) {
  const state = useTraceState(active);
  const [node, setRefs] = useTraceNode(ref);
  const [box, setBox] = useState<BorderBox | null>(null);
  useDrawAnimation(
    node,
    { mode, active, duration, exitDuration, exitDirection },
    box,
  );

  useLayoutEffect(() => {
    const parent = node?.parentElement;

    if (!parent) {
      return;
    }

    const update = () => {
      const next = readBorderBox(parent);
      setBox((current) =>
        current &&
        current.width === next.width &&
        current.height === next.height &&
        current.offset === next.offset
          ? current
          : next,
      );
    };

    update();
    const observer = new ResizeObserver(update);
    observer.observe(parent, { box: "border-box" });

    return () => observer.disconnect();
  }, [node]);

  return (
    <svg
      {...rootProps({ mode, duration, variant, exitDirection }, state, {
        top: box ? -box.offset : 0,
        left: box ? -box.offset : 0,
        width: box?.width ?? 0,
        height: box?.height ?? 0,
        ...style,
      })}
      {...props}
      ref={setRefs}
      data-slot="stroke-trace-border"
      className={`stroke-trace stroke-trace-border ${cn(className)}`}
    >
      {box ? (
        <rect
          pathLength={1}
          width={box.width}
          height={box.height}
          rx={Math.min(
            Number.isFinite(radius) ? Math.max(0, radius) : 0,
            box.width / 2,
            box.height / 2,
          )}
          style={{ "--stroke-trace-index": 0 } as CSSProperties}
        />
      ) : null}
    </svg>
  );
}

function assignRef<T>(ref: Ref<T> | undefined, value: T | null) {
  if (typeof ref === "function") {
    const cleanup = ref(value);
    return () => {
      if (typeof cleanup === "function") {
        cleanup();
      } else {
        ref(null);
      }
    };
  }

  if (ref) {
    ref.current = value;
  }

  return () => {
    if (ref) {
      ref.current = null;
    }
  };
}

Try the demos

Four modes
Switch modes to compare plain and neon strokes. Toggle the track or replay the animation.
Interactive borders
Draw, then clear mid-stroke to compare reverse and forward exits. Adjust the timing; focusing either field also draws both borders.
SVG artwork
Replay the signature or toggle the neon sign to explore individual stroke timing and stagger.

The pattern

A line draws itself along a stroke. Use it where the path itself means something: a loader's shape, the border of the field that has focus, the connector to the next step.

The preview is just one example. The component carries the motion; you bring the content.

Make it yours

Wrap SVG shapes in StrokeTrace, or place StrokeTraceBorder inside a positioned parent. Drive active from your UI state; change the React key to replay. Colour follows currentColor. Only strokes are traced; filled artwork and text need stroke paths. Shape pathLength is normalized to 1; shapes inside custom components must set pathLength={1} themselves. Borders follow parent size, with uniform border widths and an explicit circular corner radius; asymmetric borders, elliptical corners, and automatic CSS radius tracking are not supported. Reduced motion keeps fades and removes travel.

example.tsx
"use client";

import { useState } from "react";
import { StrokeTrace, StrokeTraceBorder } from "@/components/ui/stroke-trace";

export function Example() {
  const [focused, setFocused] = useState(false);
  return (
    <div className="flex flex-col items-start gap-6">
      {/* Any SVG shape: path, circle, rect, line, polyline, polygon. */}
      <StrokeTrace mode="loop" track viewBox="0 0 24 24" className="size-8">
        <circle cx="12" cy="12" r="9" />
      </StrokeTrace>

      {/* Traces the parent's border; the parent must be positioned. */}
      <div className="relative rounded-[8px]">
        <input
          className="h-9 rounded-[8px] border px-3"
          onFocus={() => setFocused(true)}
          onBlur={() => setFocused(false)}
        />
        <StrokeTraceBorder
          active={focused}
          radius={8}
          duration={600}
          exitDuration={250}
          exitDirection="reverse"
          className="text-sky-500"
        />
      </div>
    </div>
  );
}

API Reference

Both components accept native SVG props, including className and style. StrokeTraceBorder generates its own rectangle and does not accept children, stagger, or track. All times are in milliseconds; all props are optional.

Shared props

Shared props: prop types, defaults, and behavior
PropTypeDefaultDescription
mode"draw" | "trace" | "loop" | "orbit""draw"Draw holds the line; trace draws and clears once; loop repeats; orbit circles a short segment.
activebooleantrueEnables the effect. Draw exits when false; the other modes stop.
durationnumber1000; orbit: 1200Milliseconds for a full draw or one trace, loop, or orbit cycle. Negative values become 0; non-finite values use the default.
exitDirection"reverse" | "forward""reverse"Draw only. Retract along the path, or move the tail forward to clear it. Both resume from the current segment when interrupted.
exitDurationnumberduration × 0.6Draw only. Milliseconds for a full exit; partial exits finish proportionally sooner. Uses each shape’s duration when not supplied.
variant"line" | "neon""line"Plain stroke or a near-white core with a currentColor glow. Neon is intended for dark surfaces.
refRef<SVGSVGElement>—The SVG DOM node. Supports object refs and callback refs, including React 19 cleanup.
aria-hiddenboolean | "true" | "false"trueDecorative by default. For meaningful artwork, set false and provide role="img" and an accessible name.

StrokeTrace

StrokeTrace: prop types, defaults, and behavior
PropTypeDefaultDescription
childrenReactNode—SVG paths, circles, rectangles, ellipses, lines, polylines, or polygons. Groups and fragments are traversed.
staggernumber0Milliseconds between shape starts, in document order. Negative or non-finite values become 0.
trackbooleanfalseAdds a faint, static outline below each animated shape.

StrokeTraceBorder

StrokeTraceBorder: prop types, defaults, and behavior
PropTypeDefaultDescription
radiusnumber0Uniform corner radius in px. Updates with the prop and is clamped to half the smaller box dimension. Negative or non-finite values become 0.

Installation