Text FXAdvanced Text Animationsno dependencies

TextOnPath

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

MOTION FOUNDRY

Customize

8 props
1
40

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add text-on-path

Writes 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.

example.tsx
1import { TextOnPath } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <TextOnPath />6  );7}

Props

PropTypeDefault
text

Text that flows along the path (repeated to fill it).

string'MOTION FOUNDRY'
separator

Separator inserted between repeats.

string' • '
path

Path shape preset.

"wave" | "circle" | "arc"'wave'
speed

Flow speed multiplier (0 = static).

number1
direction

Flow direction along the path.

"forward" | "reverse"'forward'
showPath

Draw the dotted guide path underneath the text.

booleantrue
gradient

Fill the text with a gradient from the text color to the accent color.

booleantrue
color

Text color (defaults to the theme foreground).

stringundefined
accentColor

Accent color used for the gradient end.

string'#a78bfa'
pathColor

Guide path stroke color (defaults to the theme strong border).

stringundefined
fontSize

Font size in SVG user units.

number40
height

Height of the drawing in pixels.

number340
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element (CSS variables included).

React.CSSPropertiesundefined

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

components/ui/text-on-path.tsx
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}