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