# Gyre — Animated Text Effect for React

> Text flowing along an SVG path on a seamless loop — circle, wave, figure-eight, arc or a custom path. Scroll velocity boosts or flips the drift, drag scrubs it with inertia, arrow keys fling it, and an intro draws the path in before the text unveils along it. Static under reduced motion.

- Page: https://crucibleui.com/components/text/gyre
- Category: [text](https://crucibleui.com/components/text)
- Tier: free (full source)
- Engine: motion

## Install

```bash
npx shadcn@latest add @crucible/gyre
```

Registry JSON: https://crucibleui.com/r/gyre.json

Import from `@/components/crucible/gyre`.

## Props (`GyreProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `text?` | `string` | — | The phrase that flows along the path. Repeated internally to fill it; exposed once to assistive tech. |
| `preset?` | `GyrePreset` | `"circle"` | Built-in path shape. Ignored when `path` is set. |
| `path?` | `string` | — | Custom SVG path `d` string. Paths ending in `Z` loop seamlessly as closed shapes. |
| `viewBox?` | `string` | — | viewBox for a custom `path`. When omitted it is derived from the path's bounding box after mount (padded for glyph height). |
| `speed?` | `number` | `60` | Drift speed in viewBox units per second. Negative reverses. |
| `scrollReactive?` | `false \| "speed" \| "direction"` | `"speed"` | Couple the drift to page scroll. `"speed"` adds a smoothed boost from scroll velocity that decays back to base; `"direction"` also flips travel with the scroll direction. |
| `draggable?` | `boolean` | `true` | Pointer-drag scrubs the text along the path, gliding on release. Also enables arrow-key nudges. |
| `pauseOnHover?` | `boolean` | `true` | Ease the drift to a halt while a mouse hovers the text. |
| `showPath?` | `boolean` | `false` | Draw a faint guide stroke along the path (it draws in during the intro). |
| `separator?` | `string` | `" • "` | Joins each repetition of `text`. Rendered at reduced opacity. |
| `fontSize?` | `number` | `per preset (circle 30, wave 34, loop 28, arc 34, custom 24)` | Glyph size in viewBox units. |
| `tracking?` | `number` | `0.04` | Extra letter spacing in em, applied before seam-fitting. |
| `color?` | `string` | ``currentColor` (inherits — neutral-100 out of the box)` | Text color. |
| `paused?` | `boolean` | `false` | Freeze the autoplay drift and scroll coupling. Dragging still works. |
| `ref?` | `React.Ref<GyreHandle>` | — | Imperative handle — `replay()` and `nudge(velocity?)`. |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "children" | "color">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { Gyre, type GyreHandle } from "@/components/crucible/gyre";

export default function GyreDemo() {
  const badge = React.useRef<GyreHandle>(null);
  const wave = React.useRef<GyreHandle>(null);

  return (
    <div className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950">
      <div
        aria-hidden
        className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_center,rgba(255,255,255,0.04),transparent_70%)]"
      />
      <div className="relative z-10 flex w-full max-w-3xl flex-col items-center gap-6 px-6 py-10">
        <div className="flex w-full flex-col items-center gap-6 sm:flex-row sm:justify-center sm:gap-10">
          <div className="relative w-44 shrink-0 sm:w-52">
            <Gyre
              ref={badge}
              preset="circle"
              text="crafted • shipped • iterated"
              speed={45}
              fontSize={34}
              tracking={0.14}
              className="font-semibold uppercase text-neutral-100"
            />
            <div
              aria-hidden
              className="pointer-events-none absolute inset-[26%] flex items-center justify-center rounded-full border border-white/10 bg-white/[0.03]"
            >
              <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" className="text-neutral-400">
                <path d="M7 17 17 7M9 7h8v8" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </div>
          </div>
          <div className="w-full max-w-md">
            <Gyre
              ref={wave}
              preset="wave"
              text="Scroll or drag the text"
              scrollReactive="direction"
              showPath
              speed={70}
              className="font-medium text-neutral-300"
            />
          </div>
        </div>
        <div className="flex flex-col items-center gap-3">
          <p className="text-sm text-neutral-400">Scroll or drag the text. Arrow keys fling it.</p>
          <button
            type="button"
            onClick={() => {
              badge.current?.replay();
              wave.current?.replay();
            }}
            className="rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-neutral-400 transition-colors hover:bg-white/5 hover:text-neutral-200"
          >
            Replay intro
          </button>
        </div>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.

## Source (`gyre.tsx`)

```tsx
"use client";

import * as React from "react";

import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { useVisibilityPause } from "@/hooks/use-visibility-pause";

/** Imperative handle exposed through `GyreProps.ref`. */
export interface GyreHandle {
  /** Re-run the intro: the guide path draws in again and the text re-reveals along it. */
  replay: () => void;
  /**
   * Fling the text along the path. Impulses stack with any glide already in
   * progress. Positive = forward along the path. @default 260 (units/s)
   */
  nudge: (velocity?: number) => void;
}

export type GyrePreset = "circle" | "wave" | "loop" | "arc";

export interface GyreProps
  extends Omit<React.ComponentPropsWithoutRef<"div">, "children" | "color"> {
  /** The phrase that flows along the path. Repeated internally to fill it; exposed once to assistive tech. */
  text?: string;
  /** Built-in path shape. Ignored when `path` is set. @default "circle" */
  preset?: GyrePreset;
  /** Custom SVG path `d` string. Paths ending in `Z` loop seamlessly as closed shapes. */
  path?: string;
  /**
   * viewBox for a custom `path`. When omitted it is derived from the path's
   * bounding box after mount (padded for glyph height).
   */
  viewBox?: string;
  /** Drift speed in viewBox units per second. Negative reverses. @default 60 */
  speed?: number;
  /**
   * Couple the drift to page scroll. `"speed"` adds a smoothed boost from scroll
   * velocity that decays back to base; `"direction"` also flips travel with the
   * scroll direction. @default "speed"
   */
  scrollReactive?: false | "speed" | "direction";
  /** Pointer-drag scrubs the text along the path, gliding on release. Also enables arrow-key nudges. @default true */
  draggable?: boolean;
  /** Ease the drift to a halt while a mouse hovers the text. @default true */
  pauseOnHover?: boolean;
  /** Draw a faint guide stroke along the path (it draws in during the intro). @default false */
  showPath?: boolean;
  /** Joins each repetition of `text`. Rendered at reduced opacity. @default " • " */
  separator?: string;
  /** Glyph size in viewBox units. Defaults per preset (circle 30, wave 34, loop 28, arc 34, custom 24). */
  fontSize?: number;
  /** Extra letter spacing in em, applied before seam-fitting. @default 0.04 */
  tracking?: number;
  /** Text color. Defaults to `currentColor` (inherits — neutral-100 out of the box). */
  color?: string;
  /** Freeze the autoplay drift and scroll coupling. Dragging still works. @default false */
  paused?: boolean;
  /** Imperative handle — `replay()` and `nudge(velocity?)`. */
  ref?: React.Ref<GyreHandle>;
}

interface PresetDef {
  d: string;
  viewBox: string;
  fontSize: number;
}

const PRESETS: Record<GyrePreset, PresetDef> = {
  // Starts at 12 o'clock, travels clockwise; glyphs ride the outside like a badge.
  circle: {
    d: "M 200 50 A 150 150 0 1 1 200 350 A 150 150 0 1 1 200 50 Z",
    viewBox: "0 0 400 400",
    fontSize: 30,
  },
  // Two full periods of a soft sine.
  wave: {
    d: "M 0 130 C 66.7 50 133.3 50 200 130 S 333.3 210 400 130 S 533.3 50 600 130 S 733.3 210 800 130",
    viewBox: "0 0 800 240",
    fontSize: 34,
  },
  // Smooth closed figure-eight.
  loop: {
    d: "M 300 160 C 380 50 560 50 560 160 S 380 270 300 160 C 220 50 40 50 40 160 S 220 270 300 160 Z",
    viewBox: "0 0 600 320",
    fontSize: 28,
  },
  // Shallow upward arch.
  arc: {
    d: "M 60 230 A 260 260 0 0 1 540 230",
    viewBox: "0 0 600 260",
    fontSize: 34,
  },
};

interface Measure {
  /** Path length. */
  L: number;
  /** Seamless period of one rendered copy. */
  T: number;
  /** Units (text + separator) per copy. */
  units: number;
  /** Letter spacing (user units) applied to the flowing copies. */
  spacing: number;
  /** Sampled path points [x0, y0, x1, y1, ...] for drag projection. */
  samples: Float32Array;
  closed: boolean;
  bbox: string;
}

const INTRO_PATH = 0.9;
const INTRO_TEXT = 1.2;

const easeOut = (t: number) => 1 - Math.pow(1 - Math.min(1, Math.max(0, t)), 3);
const mod = (a: number, n: number) => ((a % n) + n) % n;

/**
 * Gyre — a line of text flowing along an SVG path, looping seamlessly. The
 * phrase is repeated to fill the measured path length and, on closed shapes,
 * its spacing is fitted so an exact whole number of repetitions meets at the
 * seam. Base drift picks up a smoothed boost from scroll velocity; drag scrubs
 * the text along the path's own tangent with release inertia; arrow keys fling
 * it. Intro: the guide path draws in, then the text unveils along it from the
 * start. All motion is written straight to `startOffset` in one rAF loop that
 * pauses offscreen and on hidden tabs. Reduced motion: static text along the path.
 */
export function Gyre({
  text = "Scroll or drag the text",
  preset = "circle",
  path,
  viewBox,
  speed = 60,
  scrollReactive = "speed",
  draggable = true,
  pauseOnHover = true,
  showPath = false,
  separator = " • ",
  fontSize,
  tracking = 0.04,
  color,
  paused = false,
  ref,
  className,
  style,
  onPointerDown,
  onPointerMove,
  onPointerUp,
  onPointerCancel,
  onPointerEnter,
  onPointerLeave,
  onKeyDown,
  ...props
}: GyreProps) {
  const reducedMotion = useReducedMotion();
  const rawId = React.useId();
  const uid = "gyre" + rawId.replace(/[^a-zA-Z0-9_-]/g, "");
  const pathId = `${uid}-p`;
  const maskId = `${uid}-m`;
  const blurId = `${uid}-b`;

  const def = path ? null : PRESETS[preset] ?? PRESETS.circle;
  const d = path ?? def!.d;
  const size = fontSize ?? def?.fontSize ?? 24;
  const letter = tracking * size;

  const svgRef = React.useRef<SVGSVGElement>(null);
  const pathRef = React.useRef<SVGPathElement>(null);
  const measureTextRef = React.useRef<SVGTextElement>(null);
  const tpA = React.useRef<SVGTextPathElement>(null);
  const tpB = React.useRef<SVGTextPathElement>(null);
  const flowRef = React.useRef<SVGGElement>(null);
  const maskPathRef = React.useRef<SVGPathElement>(null);
  const guideRef = React.useRef<SVGPathElement>(null);

  const [measure, setMeasure] = React.useState<Measure | null>(null);
  const measureRef = React.useRef<Measure | null>(null);
  measureRef.current = measure;

  const live = React.useRef({ speed, scrollReactive, pauseOnHover, paused, draggable });
  live.current = { speed, scrollReactive, pauseOnHover, paused, draggable };

  const sim = React.useRef({
    phase: 0,
    momentum: 0,
    mul: 1,
    boost: 0,
    dir: 1,
    hovered: false,
    dragging: false,
    pointerId: -1,
    lastX: 0,
    lastY: 0,
    lastMoveT: 0,
    dragVel: 0,
    introStart: -1,
    introDone: false,
    last: 0,
    scrollY: Number.NaN,
    scrollT: 0,
    writtenA: Number.NaN,
  });

  // --- measure after mount (SSR-safe): path length, one unit's advance, samples ---
  const unitText = text + separator;
  const unitChars = [...unitText].length || 1;
  React.useEffect(() => {
    let cancelled = false;
    const run = () => {
      const p = pathRef.current;
      const mt = measureTextRef.current;
      if (cancelled || !p || !mt) return;
      const L = p.getTotalLength();
      const P = mt.getComputedTextLength();
      if (!(L > 0) || !(P > 0)) return;
      const closed = /z\s*$/i.test(d.trim());
      let units: number;
      let spacing = letter;
      let T: number;
      if (closed) {
        // Fit a whole number of units exactly into the loop so the seam vanishes.
        units = Math.max(1, Math.round(L / P));
        spacing = letter + (L / units - P) / unitChars;
        T = L;
      } else {
        units = Math.max(1, Math.ceil(L / P));
        T = units * P;
      }
      const n = Math.round(Math.min(480, Math.max(96, L / 4)));
      const samples = new Float32Array(n * 2);
      for (let i = 0; i < n; i++) {
        const pt = p.getPointAtLength((i / (closed ? n : n - 1)) * L);
        samples[i * 2] = pt.x;
        samples[i * 2 + 1] = pt.y;
      }
      const b = p.getBBox();
      const pad = size * 1.5;
      const bbox = `${b.x - pad} ${b.y - pad} ${b.width + pad * 2} ${b.height + pad * 2}`;
      sim.current.writtenA = Number.NaN;
      setMeasure({ L, T, units, spacing, samples, closed, bbox });
    };
    run();
    const fonts = typeof document !== "undefined" ? document.fonts : undefined;
    fonts?.ready.then(run).catch(() => {});
    fonts?.addEventListener?.("loadingdone", run);
    return () => {
      cancelled = true;
      fonts?.removeEventListener?.("loadingdone", run);
    };
  }, [d, unitText, unitChars, size, letter, className]);

  // Reduced-motion toggles reset the intro so the imperative mask state re-syncs.
  React.useEffect(() => {
    sim.current.introStart = -1;
    sim.current.introDone = false;
    sim.current.writtenA = Number.NaN;
  }, [reducedMotion]);

  // --- the single rAF loop ---
  const rootRef = useVisibilityPause<HTMLDivElement>(
    (elapsed) => {
      const m = measureRef.current;
      const s = sim.current;
      const cfg = live.current;
      const dt = Math.min(0.05, Math.max(0, elapsed - s.last));
      s.last = elapsed;
      if (!m) return;

      // Intro — starts the first frame the component is visible (loop is in-view gated).
      if (s.introStart < 0) {
        s.introStart = elapsed;
        flowRef.current?.setAttribute("mask", `url(#${maskId})`);
      }
      if (!s.introDone) {
        const t = elapsed - s.introStart;
        const pathP = easeOut(t / INTRO_PATH);
        const textDelay = guideRef.current ? INTRO_PATH * 0.55 : 0;
        const textP = easeOut((t - textDelay) / INTRO_TEXT);
        guideRef.current?.setAttribute("stroke-dashoffset", String(1 - pathP));
        maskPathRef.current?.setAttribute("stroke-dashoffset", String(1 - textP));
        flowRef.current?.setAttribute("opacity", String(Math.min(1, textP * 3)));
        if (pathP >= 1 && textP >= 1) {
          s.introDone = true;
          flowRef.current?.removeAttribute("mask");
        }
      }

      // Scroll velocity (window scroll), smoothed; skipped across loop pauses.
      const now = performance.now();
      let target = 0;
      if (cfg.scrollReactive && !cfg.paused) {
        const y = window.scrollY;
        if (!Number.isNaN(s.scrollY) && now - s.scrollT < 120 && dt > 0) {
          const raw = Math.max(-6000, Math.min(6000, (y - s.scrollY) / dt));
          if (Math.abs(raw) > 30) s.dir = raw > 0 ? 1 : -1;
          target = raw * 0.45;
        }
        s.scrollY = y;
        s.scrollT = now;
      }
      s.boost += (target - s.boost) * (1 - Math.exp(-dt * 5));

      const halt = cfg.paused || s.dragging || (cfg.pauseOnHover && s.hovered);
      s.mul += ((halt ? 0 : 1) - s.mul) * (1 - Math.exp(-dt * 6));

      const base = cfg.paused ? 0 : cfg.speed;
      const sign = base === 0 ? 1 : Math.sign(base);
      let v: number;
      if (cfg.scrollReactive === "direction") {
        v = s.dir * (Math.abs(base) + Math.abs(s.boost));
      } else if (cfg.scrollReactive === "speed") {
        v = base + sign * Math.abs(s.boost);
      } else {
        v = base;
      }

      if (!s.dragging) {
        s.momentum *= Math.exp(-dt * 2.4);
        if (Math.abs(s.momentum) < 0.5) s.momentum = 0;
      }
      s.phase = mod(s.phase + (v * s.mul + (s.dragging ? 0 : s.momentum)) * dt, m.T);

      if (Math.abs(s.phase - s.writtenA) > 0.01 || Number.isNaN(s.writtenA)) {
        s.writtenA = s.phase;
        tpA.current?.setAttribute("startOffset", String(s.phase));
        tpB.current?.setAttribute("startOffset", String(s.phase - m.T));
      }
    },
    { paused: reducedMotion }
  );

  React.useImperativeHandle(
    ref,
    () => ({
      replay: () => {
        const s = sim.current;
        s.introStart = -1;
        s.introDone = false;
      },
      nudge: (velocity = 260) => {
        sim.current.momentum += velocity;
      },
    }),
    []
  );

  // --- drag: project pointer motion onto the path tangent nearest the pointer ---
  const toUser = (clientX: number, clientY: number) => {
    const svg = svgRef.current;
    const ctm = svg?.getScreenCTM();
    if (!svg || !ctm) return null;
    const inv = ctm.inverse();
    return {
      x: inv.a * clientX + inv.c * clientY + inv.e,
      y: inv.b * clientX + inv.d * clientY + inv.f,
    };
  };

  const interactive = draggable && !reducedMotion;

  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    onPointerDown?.(e);
    if (!interactive || e.button !== 0 || e.defaultPrevented) return;
    const pt = toUser(e.clientX, e.clientY);
    if (!pt) return;
    const s = sim.current;
    s.dragging = true;
    s.pointerId = e.pointerId;
    s.lastX = pt.x;
    s.lastY = pt.y;
    s.lastMoveT = performance.now();
    s.dragVel = 0;
    s.momentum = 0;
    e.currentTarget.setPointerCapture(e.pointerId);
  };

  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    onPointerMove?.(e);
    const s = sim.current;
    const m = measureRef.current;
    if (!s.dragging || e.pointerId !== s.pointerId || !m) return;
    const pt = toUser(e.clientX, e.clientY);
    if (!pt) return;
    const dx = pt.x - s.lastX;
    const dy = pt.y - s.lastY;
    s.lastX = pt.x;
    s.lastY = pt.y;
    // Nearest sample to the pointer → local tangent.
    const smp = m.samples;
    const n = smp.length / 2;
    let best = 0;
    let bestD = Infinity;
    for (let i = 0; i < n; i++) {
      const ex = smp[i * 2] - pt.x;
      const ey = smp[i * 2 + 1] - pt.y;
      const dd = ex * ex + ey * ey;
      if (dd < bestD) {
        bestD = dd;
        best = i;
      }
    }
    const i0 = m.closed ? mod(best - 1, n) : Math.max(0, best - 1);
    const i1 = m.closed ? mod(best + 1, n) : Math.min(n - 1, best + 1);
    let tx = smp[i1 * 2] - smp[i0 * 2];
    let ty = smp[i1 * 2 + 1] - smp[i0 * 2 + 1];
    const len = Math.hypot(tx, ty) || 1;
    tx /= len;
    ty /= len;
    const along = dx * tx + dy * ty;
    s.phase = mod(s.phase + along, m.T);
    const now = performance.now();
    const elapsedMs = Math.max(1, now - s.lastMoveT);
    s.lastMoveT = now;
    const inst = (along / elapsedMs) * 1000;
    s.dragVel += (inst - s.dragVel) * 0.35;
  };

  const endDrag = (e: React.PointerEvent<HTMLDivElement>) => {
    const s = sim.current;
    if (!s.dragging || e.pointerId !== s.pointerId) return;
    s.dragging = false;
    // Stale velocity (pointer held still before release) shouldn't fling.
    const idle = performance.now() - s.lastMoveT;
    const v = idle > 90 ? 0 : s.dragVel;
    s.momentum = Math.max(-3200, Math.min(3200, v));
    if (e.currentTarget.hasPointerCapture(e.pointerId)) {
      e.currentTarget.releasePointerCapture(e.pointerId);
    }
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
    onKeyDown?.(e);
    if (!interactive || e.defaultPrevented) return;
    const step = e.shiftKey ? 780 : 260;
    if (e.key === "ArrowRight" || e.key === "ArrowDown") {
      e.preventDefault();
      sim.current.momentum += step;
    } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
      e.preventDefault();
      sim.current.momentum -= step;
    }
  };

  const vb = viewBox ?? def?.viewBox ?? measure?.bbox ?? "0 0 400 400";
  const vbn = vb.split(/[\s,]+/).map(Number);
  const [vx = 0, vy = 0, vw = 400, vh = 400] = vbn;

  const units = measure?.units ?? 0;
  const copy = (key: string, r: React.Ref<SVGTextPathElement>, offset: number) => (
    <text
      key={key}
      fontSize={size}
      letterSpacing={measure?.spacing ?? letter}
      fill={color ?? "currentColor"}
      style={{ whiteSpace: "pre" }}
    >
      <textPath ref={r} href={`#${pathId}`} startOffset={offset}>
        {Array.from({ length: units }, (_, i) => (
          <React.Fragment key={i}>
            <tspan>{text}</tspan>
            <tspan fillOpacity={0.45}>{separator}</tspan>
          </React.Fragment>
        ))}
      </textPath>
    </text>
  );

  return (
    <div
      ref={rootRef}
      data-crucible="gyre"
      className={cn(
        "relative block w-full select-none text-neutral-100 outline-none",
        interactive && "cursor-grab active:cursor-grabbing",
        "rounded-xl focus-visible:ring-2 focus-visible:ring-white/30 focus-visible:ring-offset-0",
        className
      )}
      style={{ touchAction: interactive ? "pan-y" : undefined, ...style }}
      tabIndex={interactive ? 0 : undefined}
      // A focusable generic div needs a role for its label/roledescription to be exposed.
      role={interactive ? "group" : undefined}
      aria-label={interactive ? "Scroll text, use arrow keys" : undefined}
      aria-roledescription={interactive ? "marquee" : undefined}
      onPointerDown={handlePointerDown}
      onPointerMove={handlePointerMove}
      onPointerUp={(e) => {
        onPointerUp?.(e);
        endDrag(e);
      }}
      onPointerCancel={(e) => {
        onPointerCancel?.(e);
        endDrag(e);
      }}
      onPointerEnter={(e) => {
        onPointerEnter?.(e);
        if (e.pointerType === "mouse") sim.current.hovered = true;
      }}
      onPointerLeave={(e) => {
        onPointerLeave?.(e);
        sim.current.hovered = false;
      }}
      onKeyDown={handleKeyDown}
      {...props}
    >
      <span className="sr-only">{text}</span>
      <svg
        ref={svgRef}
        aria-hidden
        focusable="false"
        viewBox={vb}
        className="pointer-events-none block h-auto w-full overflow-visible"
      >
        <defs>
          <path id={pathId} ref={pathRef} d={d} />
          <filter
            id={blurId}
            filterUnits="userSpaceOnUse"
            x={vx - vw}
            y={vy - vh}
            width={vw * 3}
            height={vh * 3}
          >
            <feGaussianBlur stdDeviation={size * 0.45} />
          </filter>
          <mask
            id={maskId}
            maskUnits="userSpaceOnUse"
            x={vx - vw}
            y={vy - vh}
            width={vw * 3}
            height={vh * 3}
          >
            <path
              ref={maskPathRef}
              d={d}
              fill="none"
              stroke="#fff"
              strokeWidth={size * 3.4}
              pathLength={1}
              strokeDasharray="1 1"
              strokeDashoffset={1}
              filter={`url(#${blurId})`}
            />
          </mask>
        </defs>

        {/* Hidden ruler: one unit's advance width (same font, same tracking). */}
        <text
          ref={measureTextRef}
          x={0}
          y={0}
          fontSize={size}
          letterSpacing={letter}
          visibility="hidden"
          style={{ whiteSpace: "pre" }}
        >
          <tspan>{text}</tspan>
          <tspan>{separator}</tspan>
        </text>

        {showPath && (
          <path
            ref={guideRef}
            d={d}
            fill="none"
            stroke={color ?? "currentColor"}
            strokeOpacity={0.18}
            strokeWidth={Math.max(1, size * 0.04)}
            strokeLinecap="round"
            pathLength={1}
            strokeDasharray="1 1"
            strokeDashoffset={reducedMotion ? 0 : 1}
          />
        )}

        <g
          ref={flowRef}
          opacity={reducedMotion ? 1 : 0}
          mask={reducedMotion ? undefined : `url(#${maskId})`}
        >
          {measure && copy("a", tpA, 0)}
          {measure && copy("b", tpB, -measure.T)}
        </g>
      </svg>
    </div>
  );
}
```
