"use client";
import * as React from "react";
import { useMotionValueEvent, useScroll, useSpring } from "motion/react";
type ScrollOffset = NonNullable<NonNullable<Parameters<typeof useScroll>[0]>["offset"]>;
export type ScrollTextPathShape = "wave" | "arc" | "loop";
export type ScrollTextPathDirection = "forward" | "reverse";
/** Which part of the line takes the accent colour. */
export type ScrollTextPathAccent = "separator" | "text" | "alternate";
export interface ScrollTextPathProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
/** The phrase set along the path. Exposed once to assistive technology. */
text?: string;
/** Built-in path shape. Ignored when `d` is passed. */
path?: ScrollTextPathShape;
/** Custom SVG path data. Coordinates use `viewBox`. */
d?: string;
/** viewBox for a custom `d`. Built-in shapes use "0 0 1000 360". */
viewBox?: string;
/** Scrollable ancestor to track. Defaults to the window. */
container?: React.RefObject<HTMLElement | null>;
/** Scroll offsets, as in motion's useScroll: when the travel starts and ends. */
offset?: ScrollOffset;
/** Repeat the phrase so it fills the whole path. */
repeat?: boolean;
/** Inserted between repetitions. */
separator?: string;
/** Scrolling down moves the text along the path (forward) or against it (reverse). */
direction?: ScrollTextPathDirection;
/** Travel multiplier: how far the text moves per unit of scroll. */
speed?: number;
/** Font size in viewBox units (the type scales with the element's width). */
fontSize?: number;
/** Which part of the line is drawn in the accent colour. */
accentMode?: ScrollTextPathAccent;
/** Draw the path itself as a hairline guide. */
guide?: boolean;
/** Smooth scroll progress with a light spring. */
smooth?: boolean;
/** Accent colour (any CSS colour). Deepened automatically on light backgrounds. */
accent?: string;
/** Element to render. */
as?: "div" | "p" | "h2" | "h3";
/** Classes for the SVG text (font, weight, case). */
textClassName?: string;
}
/* SSR-safe: renders "no preference" on the server and the first client pass, so markup always hydrates. */
function subscribeReducedMotion(onChange: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", onChange);
return () => mq.removeEventListener("change", onChange);
}
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
const SHAPE_VIEWBOX = "0 0 1000 360";
const SHAPES: Record<ScrollTextPathShape, string> = {
// One full sine period: an arch, then a dip.
wave: "M 0 190 C 150 50 350 50 500 190 S 850 330 1000 190",
// A single wide arch.
arc: "M 30 320 Q 500 -100 970 320",
// Approach, curl up and over into a loop, cross underneath and leave.
loop: "M 0 250 C 420 250 750 270 750 150 C 750 0 370 0 370 150 C 370 290 600 335 1000 335",
};
const DEFAULT_OFFSET: ScrollOffset = ["start end", "end start"];
/** Rough glyph advance relative to font size, used until real measurements exist (server render). */
const GLYPH_ADVANCE = 0.55;
const MAX_COPIES = 60;
interface Metrics {
/** Path length in viewBox units. */
path: number;
/** Width of one `text + separator` unit. */
unit: number;
/** Width of the bare text. */
text: number;
}
const round = (n: number) => Math.round(n * 100) / 100;
/**
* Text that travels along a curved SVG path as you scroll. The textPath's startOffset is
* mapped from scroll progress (window or any scrollable container) and written straight to
* the DOM, so scrolling never re-renders React.
*/
export function ScrollTextPath({
text = "Scroll to steer the type",
path = "wave",
d,
viewBox,
container,
offset = DEFAULT_OFFSET,
repeat = true,
separator = " • ",
direction = "forward",
speed = 1,
fontSize = 44,
accentMode = "separator",
guide = true,
smooth = true,
accent = "#FF7448",
as = "div",
textClassName = "font-semibold tracking-[-0.02em]",
className = "",
style,
...props
}: ScrollTextPathProps) {
const reduceMotion = usePrefersReducedMotion();
const rootRef = React.useRef<HTMLElement>(null);
const pathRef = React.useRef<SVGPathElement>(null);
const textPathRef = React.useRef<SVGTextPathElement>(null);
const unitProbeRef = React.useRef<SVGTSpanElement>(null);
const textProbeRef = React.useRef<SVGTSpanElement>(null);
const pathId = `stp-${React.useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
const pathData = d ?? SHAPES[path] ?? SHAPES.wave;
const box = d ? (viewBox ?? "0 0 1000 360") : SHAPE_VIEWBOX;
// Deterministic estimate for the server render; replaced by real measurements after mount.
const [metrics, setMetrics] = React.useState<Metrics>(() => ({
path: 1200,
unit: Math.max(1, Array.from(text + separator).length * fontSize * GLYPH_ADVANCE),
text: Math.max(1, Array.from(text).length * fontSize * GLYPH_ADVANCE),
}));
// Measure path and text lengths (in viewBox units, so they don't change on resize; only fonts matter).
React.useEffect(() => {
let cancelled = false;
const measure = () => {
if (cancelled) return;
const pathEl = pathRef.current;
const unitEl = unitProbeRef.current;
const textEl = textProbeRef.current;
if (!pathEl || !unitEl || !textEl) return;
const next: Metrics = {
path: round(pathEl.getTotalLength()),
unit: Math.max(1, round(unitEl.getComputedTextLength())),
text: Math.max(1, round(textEl.getComputedTextLength())),
};
setMetrics((prev) => (prev.path === next.path && prev.unit === next.unit && prev.text === next.text ? prev : next));
};
const frame = requestAnimationFrame(measure);
document.fonts?.ready.then(measure).catch(() => {});
return () => {
cancelled = true;
cancelAnimationFrame(frame);
};
}, [text, separator, fontSize, pathData, textClassName]);
const { scrollYProgress } = useScroll({ target: rootRef, container, offset });
const smoothed = useSpring(scrollYProgress, { stiffness: 140, damping: 28, mass: 0.4, restDelta: 0.0005 });
const progress = smooth ? smoothed : scrollYProgress;
const speedFactor = Math.max(0, speed);
// Repeat mode slides a long ribbon by half the path length (× speed); copies cover path + travel.
const travel = metrics.path * 0.5 * speedFactor;
const copies = repeat
? Math.min(MAX_COPIES, Math.max(1, Math.ceil((metrics.path + travel) / metrics.unit) + 1))
: 1;
/** startOffset (viewBox units) for scroll progress `p`. */
const offsetAt = (p: number) => {
const t = Math.min(1, Math.max(0, p));
if (repeat) return direction === "forward" ? -travel + t * travel : -t * travel;
// A single phrase enters at one end and leaves at the other.
const span = (metrics.path + metrics.text) * speedFactor;
return direction === "forward" ? -metrics.text + t * span : metrics.path - t * span;
};
// Static placement for reduced motion and the server render.
const restingOffset = repeat ? 0 : Math.max(0, (metrics.path - metrics.text) / 2);
const apply = (p: number) => {
textPathRef.current?.setAttribute("startOffset", String(round(offsetAt(p))));
};
React.useLayoutEffect(() => {
if (reduceMotion) textPathRef.current?.setAttribute("startOffset", String(round(restingOffset)));
else apply(progress.get());
});
useMotionValueEvent(progress, "change", (p) => {
if (!reduceMotion) apply(p);
});
const Tag = as as React.ElementType;
const accentFill = "var(--stp-ink)";
const unitFill = (i: number) => {
if (accentMode === "text") return { text: accentFill, sep: undefined };
if (accentMode === "alternate") return { text: i % 2 === 1 ? accentFill : undefined, sep: undefined };
return { text: undefined, sep: accentFill };
};
return (
<Tag
ref={rootRef}
className={
// On light backgrounds the accent is deepened towards ink so accented words keep their contrast.
"relative block w-full text-zinc-900 [--stp-ink:color-mix(in_oklab,var(--stp-accent)_78%,#18181b)] dark:text-zinc-100 dark:[--stp-ink:var(--stp-accent)] " +
className
}
style={{ ["--stp-accent" as string]: accent, ...style }}
{...props}
>
<span className="sr-only">{text}</span>
<svg aria-hidden="true" viewBox={box} className="block h-auto w-full overflow-hidden" focusable="false">
<defs>
<path id={pathId} ref={pathRef} d={pathData} />
</defs>
{guide ? (
// Drawn just below the baseline so it reads as a rule following the curve.
<use
href={`#${pathId}`}
transform={`translate(0 ${round(fontSize * 0.3)})`}
fill="none"
stroke="currentColor"
strokeOpacity={0.35}
strokeWidth={1}
strokeLinecap="round"
strokeDasharray="1 5"
vectorEffect="non-scaling-stroke"
/>
) : null}
{/* Invisible probes measure the real advance of one unit and of the bare text. */}
<text className={textClassName} style={{ fontSize, whiteSpace: "pre" }} visibility="hidden">
<tspan ref={unitProbeRef}>{text + separator}</tspan>
</text>
<text className={textClassName} style={{ fontSize, whiteSpace: "pre" }} visibility="hidden">
<tspan ref={textProbeRef}>{text}</tspan>
</text>
<text className={`fill-current ${textClassName}`} style={{ fontSize, whiteSpace: "pre" }}>
<textPath ref={textPathRef} href={`#${pathId}`} startOffset={round(restingOffset)}>
{repeat
? Array.from({ length: copies }, (_, i) => {
const fill = unitFill(i);
return (
<React.Fragment key={i}>
<tspan fill={fill.text}>{text}</tspan>
<tspan fill={fill.sep} fillOpacity={fill.sep ? 1 : 0.4}>
{separator}
</tspan>
</React.Fragment>
);
})
: (
<tspan fill={accentMode === "separator" ? undefined : accentFill}>{text}</tspan>
)}
</textPath>
</text>
</svg>
</Tag>
);
}