Repeating label that flows endlessly along an SVG wave, circle, or arc path via an animated textPath startOffset, with an optional dotted guide stroke and a text-to-accent gradient fill; repeats are fitted to the path length so the loop is seamless.
Preview
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add text-on-pathWrites components/ui/text-on-path.tsx and components/styles/text-on-path.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { TextOnPath } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <TextOnPath />6 );7}Props
Accessibility
The label is exposed once in a visually hidden span; the SVG with repeated copies is aria-hidden. Each instance uses useId for unique path and gradient ids. Under prefers-reduced-motion (or speed 0) the text is static. The loop pauses off-screen via IntersectionObserver and is cancelled on unmount.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/text-on-path.module.css";5 6export type TextOnPathPreset = "wave" | "circle" | "arc";7 8export interface TextOnPathProps {9 /** Text that flows along the path (repeated to fill it) */10 text?: string;11 /** Separator inserted between repeats */12 separator?: string;13 /** Path shape preset */14 path?: TextOnPathPreset;15 /** Flow speed multiplier (0 = static) */16 speed?: number;17 /** Flow direction along the path */18 direction?: "forward" | "reverse";19 /** Draw the guide path underneath the text */20 showPath?: boolean;21 /** Fill the text with a gradient from the text color to the accent color */22 gradient?: boolean;23 /** Text color (defaults to the theme foreground) */24 color?: string;25 /** Accent color used for the gradient end */26 accentColor?: string;27 /** Guide path stroke color (defaults to the theme strong border) */28 pathColor?: string;29 /** Font size in SVG user units */30 fontSize?: number;31 /** Height of the drawing in pixels */32 height?: number;33 /** Optional className merged onto the root */34 className?: string;35 /** Optional inline styles merged onto the root */36 style?: React.CSSProperties;37}38 39const PRESETS: Record<TextOnPathPreset, { w: number; h: number; d: string; fontScale: number }> = {40 wave: {41 w: 1000,42 h: 320,43 d: "M 0 160 C 83 50, 167 50, 250 160 S 417 270, 500 160 S 667 50, 750 160 S 917 270, 1000 160",44 fontScale: 1,45 },46 circle: {47 w: 600,48 h: 600,49 d: "M 300 85 A 215 215 0 1 1 300 515 A 215 215 0 1 1 300 85",50 fontScale: 1.25,51 },52 arc: {53 w: 1000,54 h: 420,55 d: "M 90 370 Q 500 -50 910 370",56 fontScale: 1.1,57 },58};59 60const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;61 62interface Fit {63 copies: number;64 unit: number;65}66 67export function TextOnPath({68 text = "MOTION FOUNDRY",69 separator = " • ",70 path = "wave",71 speed = 1,72 direction = "forward",73 showPath = true,74 gradient = true,75 color,76 accentColor = "#a78bfa",77 pathColor,78 fontSize = 40,79 height = 340,80 className = "",81 style,82}: TextOnPathProps) {83 const rawId = React.useId();84 const uid = `mf-tp-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`;85 const preset = PRESETS[path] ?? PRESETS.wave;86 const size = fontSize * preset.fontScale;87 const unitText = `${text}${separator}`;88 89 const svgRef = React.useRef<SVGSVGElement | null>(null);90 const pathRef = React.useRef<SVGPathElement | null>(null);91 const measureRef = React.useRef<SVGTextElement | null>(null);92 const textPathRef = React.useRef<SVGTextPathElement | null>(null);93 const [fit, setFit] = React.useState<Fit | null>(null);94 95 useIsoLayoutEffect(() => {96 const measure = () => {97 const p = pathRef.current;98 const m = measureRef.current;99 if (!p || !m) return;100 const total = p.getTotalLength();101 const raw = m.getComputedTextLength();102 if (!(total > 0) || !(raw > 0)) return;103 const k = Math.max(1, Math.round(total / raw));104 setFit({ copies: k + 1, unit: total / k });105 };106 measure();107 let cancelled = false;108 if (typeof document !== "undefined" && document.fonts) {109 document.fonts.ready.then(() => {110 if (!cancelled) measure();111 });112 }113 return () => {114 cancelled = true;115 };116 }, [unitText, size, path]);117 118 React.useEffect(() => {119 const tp = textPathRef.current;120 const svg = svgRef.current;121 if (!tp || !svg || !fit) return;122 const unit = fit.unit;123 const place = (phase: number) => {124 const m = ((phase % unit) + unit) % unit;125 const offset = direction === "reverse" ? -m : m - unit;126 tp.setAttribute("startOffset", offset.toFixed(2));127 };128 place(0);129 const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;130 if (reduced || speed === 0) return;131 132 let raf = 0;133 let visible = true;134 let phase = 0;135 let last = performance.now();136 const pxPerSec = 60 * speed;137 const tick = (now: number) => {138 const dt = Math.min(0.1, (now - last) / 1000);139 last = now;140 phase += pxPerSec * dt;141 place(phase);142 raf = visible ? requestAnimationFrame(tick) : 0;143 };144 raf = requestAnimationFrame(tick);145 146 let io: IntersectionObserver | null = null;147 if (typeof IntersectionObserver !== "undefined") {148 io = new IntersectionObserver((entries) => {149 visible = entries.some((e) => e.isIntersecting);150 if (visible && !raf) {151 last = performance.now();152 raf = requestAnimationFrame(tick);153 }154 });155 io.observe(svg);156 }157 return () => {158 if (raf) cancelAnimationFrame(raf);159 io?.disconnect();160 };161 }, [fit, speed, direction]);162 163 const copies = fit ? fit.copies : 6;164 const flowing = Array.from({ length: copies }, () => unitText).join("");165 const vars = {166 "--mf-tp-height": `${height}px`,167 "--mf-tp-accent": accentColor,168 ...(color ? { "--mf-tp-color": color } : {}),169 ...(pathColor ? { "--mf-tp-path": pathColor } : {}),170 ...style,171 } as React.CSSProperties;172 173 return (174 <div className={`${styles.root} ${className}`.trim()} style={vars}>175 <span className={styles.srOnly}>{text}</span>176 <svg177 ref={svgRef}178 aria-hidden="true"179 className={styles.svg}180 viewBox={`0 0 ${preset.w} ${preset.h}`}181 preserveAspectRatio="xMidYMid meet"182 >183 <defs>184 <path id={`${uid}-path`} ref={pathRef} d={preset.d} />185 <linearGradient id={`${uid}-grad`} gradientUnits="userSpaceOnUse" x1="0" y1="0" x2={preset.w} y2="0">186 <stop offset="0%" className={styles.stopA} />187 <stop offset="100%" className={styles.stopB} />188 </linearGradient>189 </defs>190 {showPath ? <use href={`#${uid}-path`} className={styles.guide} /> : null}191 <text ref={measureRef} className={styles.measure} fontSize={size}>192 {unitText}193 </text>194 <text195 className={styles.text}196 fontSize={size}197 style={gradient ? { fill: `url(#${uid}-grad)` } : undefined}198 dominantBaseline="central"199 >200 <textPath201 ref={textPathRef}202 href={`#${uid}-path`}203 textLength={fit ? (fit.unit * fit.copies).toFixed(2) : undefined}204 lengthAdjust="spacing"205 >206 {flowing}207 </textPath>208 </text>209 </svg>210 </div>211 );212}