{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "stroke-draw-text",
  "type": "registry:ui",
  "title": "Stroke Draw Text",
  "description": "Display text drawn as a self-tracing outline, letter by letter, that then fills in.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/stroke-draw-text.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\nexport type StrokeDrawTrigger = \"view\" | \"hover\" | \"mount\";\ntype Tag = \"h1\" | \"h2\" | \"h3\" | \"p\" | \"div\" | \"span\";\n\nexport interface StrokeDrawTextProps extends Omit<React.HTMLAttributes<HTMLElement>, \"children\"> {\n  /** The display text (a single line). */\n  children: string;\n  /** Element to render. */\n  as?: Tag;\n  /** `view` draws once when scrolled into view, `mount` draws immediately, `hover` shows the text and redraws on hover. */\n  trigger?: StrokeDrawTrigger;\n  /** Time for one letter's outline to draw, in ms. */\n  duration?: number;\n  /** Delay between consecutive letters, in ms. */\n  stagger?: number;\n  /** Outline width relative to the glyphs (1 = 1% of the font size). */\n  strokeWidth?: number;\n  /** Fill the letters in once the outlines are drawn. */\n  fill?: boolean;\n  /** Outline colour (any CSS colour). The fill uses the current text colour. */\n  accent?: string;\n  /** Letter spacing in em, applied inside the SVG. */\n  tracking?: number;\n}\n\n/** Font size inside the SVG; every length below is in these user units. */\nconst SIZE = 100;\n/** Generous initial dash used in server markup, longer than any glyph contour at SIZE. */\nconst SSR_DASH = 900;\nconst FILL_MS = 650;\nconst EASE = \"cubic-bezier(0.45, 0.05, 0.25, 1)\";\n\n/**\n * Display text drawn as an outline that traces itself letter by letter, then fills in. Each glyph is\n * a <tspan> with its own stroke-dasharray/offset animation (Web Animations API), staggered. A glyph\n * contour's length is estimated from its advance (getComputedTextLength) — SVG has no getTotalLength\n * for text — and the dash is removed when the letter finishes, so long contours never stay clipped.\n * The viewBox is fitted to the measured text, so it scales with its container like an image.\n */\nexport function StrokeDrawText({\n  children,\n  as = \"div\",\n  trigger = \"view\",\n  duration = 1400,\n  stagger = 80,\n  strokeWidth = 1.5,\n  fill = true,\n  accent = \"#FF7448\",\n  tracking = -0.02,\n  className = \"\",\n  style,\n  onPointerEnter,\n  ...props\n}: StrokeDrawTextProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLElement | null>(null);\n  const textRef = React.useRef<SVGTextElement>(null);\n  const playRef = React.useRef<() => void>(() => {});\n  const text = children;\n  const pad = Math.ceil(strokeWidth * 2);\n  // Rough box until the real one is measured: ~0.6 em per character, ascender 0.95 em, descender 0.3 em.\n  const [viewBox, setViewBox] = React.useState(\n    `${-pad} ${-0.95 * SIZE - pad} ${Math.round(text.length * 0.6 * SIZE) + pad * 2} ${1.25 * SIZE + pad * 2}`,\n  );\n\n  const setRoot = (node: HTMLElement | null) => {\n    rootRef.current = node;\n  };\n\n  React.useLayoutEffect(() => {\n    const textEl = textRef.current;\n    const root = rootRef.current;\n    if (!textEl || !root) return;\n    const letters = Array.from(textEl.querySelectorAll(\"tspan\"));\n    let animations: Animation[] = [];\n\n    const stop = () => {\n      animations.forEach((animation) => animation.cancel());\n      animations = [];\n    };\n    // Glyph contour length ≈ 3.6 × (advance + font size) covers nearly every Latin glyph.\n    const lengths = () => letters.map((letter) => Math.ceil(3.6 * (letter.getComputedTextLength() + SIZE)));\n\n    const showFinal = () => {\n      stop();\n      for (const letter of letters) {\n        letter.style.strokeDasharray = \"none\";\n        letter.style.strokeDashoffset = \"0\";\n      }\n      textEl.style.fillOpacity = fill ? \"1\" : \"0\";\n    };\n\n    const hide = () => {\n      stop();\n      const dash = lengths();\n      letters.forEach((letter, i) => {\n        letter.style.strokeDasharray = `${dash[i]} ${dash[i]}`;\n        letter.style.strokeDashoffset = `${dash[i]}`;\n      });\n      textEl.style.fillOpacity = \"0\";\n    };\n\n    const play = () => {\n      if (animations.some((animation) => animation.playState === \"running\")) return;\n      hide();\n      const dash = lengths();\n      letters.forEach((letter, i) => {\n        const animation = letter.animate([{ strokeDashoffset: dash[i] }, { strokeDashoffset: 0 }], {\n          duration,\n          delay: i * stagger,\n          easing: EASE,\n          fill: \"both\",\n        });\n        animation.onfinish = () => {\n          letter.style.strokeDasharray = \"none\";\n        };\n        animations.push(animation);\n      });\n      if (fill) {\n        const delay = Math.max(0, letters.length - 1) * stagger + duration * 0.7;\n        const fade = textEl.animate([{ fillOpacity: 0 }, { fillOpacity: 1 }], { duration: FILL_MS, delay, easing: \"ease-out\", fill: \"both\" });\n        fade.onfinish = () => {\n          textEl.style.fillOpacity = \"1\";\n        };\n        animations.push(fade);\n      }\n    };\n\n    playRef.current = () => {\n      if (!reduceMotion) play();\n    };\n\n    let io: IntersectionObserver | null = null;\n    if (reduceMotion || trigger === \"hover\") {\n      showFinal();\n    } else if (trigger === \"mount\") {\n      play();\n    } else {\n      hide();\n      io = new IntersectionObserver(\n        ([entry]) => {\n          if (!entry.isIntersecting) return;\n          io?.disconnect();\n          play();\n        },\n        { threshold: 0.35 },\n      );\n      io.observe(root);\n    }\n\n    return () => {\n      io?.disconnect();\n      stop();\n      playRef.current = () => {};\n    };\n  }, [text, trigger, duration, stagger, fill, reduceMotion]);\n\n  // Fit the viewBox to the rendered text once webfonts have loaded.\n  React.useEffect(() => {\n    let disposed = false;\n    const measure = () => {\n      const textEl = textRef.current;\n      if (disposed || !textEl) return;\n      const box = textEl.getBBox();\n      if (!box.width) return;\n      const r = (n: number) => Math.round(n * 100) / 100;\n      setViewBox(`${r(box.x - pad)} ${r(box.y - pad)} ${r(box.width + pad * 2)} ${r(box.height + pad * 2)}`);\n    };\n    if (document.fonts) document.fonts.ready.then(measure);\n    else requestAnimationFrame(measure);\n    return () => {\n      disposed = true;\n    };\n  }, [text, pad, tracking]);\n\n  const Component = as as React.ElementType;\n  const startsHidden = trigger !== \"hover\";\n\n  // Spaces stay as plain text so they keep their width; every other character gets a tspan.\n  const parts = Array.from(text).map((char, i) =>\n    char === \" \" ? \" \" : <tspan key={i}>{char}</tspan>,\n  );\n\n  return (\n    <Component\n      ref={setRoot}\n      {...props}\n      className={`block ${className}`}\n      style={{ [\"--sd-accent\" as string]: accent, ...style }}\n      onPointerEnter={(event: React.PointerEvent<HTMLElement>) => {\n        onPointerEnter?.(event);\n        if (trigger === \"hover\") playRef.current();\n      }}\n    >\n      <span className=\"sr-only\">{text}</span>\n      <svg aria-hidden=\"true\" focusable=\"false\" viewBox={viewBox} className=\"block h-auto w-full overflow-visible\">\n        <text\n          ref={textRef}\n          x={0}\n          y={0}\n          fontSize={SIZE}\n          fill=\"currentColor\"\n          stroke=\"var(--sd-accent)\"\n          strokeWidth={strokeWidth}\n          strokeLinejoin=\"round\"\n          strokeLinecap=\"round\"\n          style={{\n            fontFamily: \"inherit\",\n            fontWeight: \"inherit\",\n            fontStyle: \"inherit\",\n            letterSpacing: `${tracking}em`,\n            whiteSpace: \"pre\",\n            // Stroke under the fill: once filled, overlapping glyph contours don't show through.\n            paintOrder: \"stroke\",\n            // Server markup starts undrawn (or finished for hover) so nothing flashes before hydration.\n            strokeDasharray: startsHidden ? `${SSR_DASH} ${SSR_DASH}` : undefined,\n            strokeDashoffset: startsHidden ? SSR_DASH : undefined,\n            fillOpacity: startsHidden ? 0 : fill ? 1 : 0,\n          }}\n        >\n          {parts}\n        </text>\n      </svg>\n    </Component>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "text"
  ],
  "docs": "Documentation: https://wancraft.my/components/stroke-draw-text"
}
