Build a Curved Text Loop in React
Flow text along a smooth SVG curve that loops continuously, creating an elegant scrolling marquee with a wave-like path.
Straight-line marquees are everywhere. A curved one stops people. Text that flows along a wave or arc has an almost hypnotic quality, like watching water move. It is unexpected, and unexpected is memorable.
The final result
CurvedLoop places text on an SVG quadratic bezier path and scrolls it in a continuous loop. You can drag it with a pointer, control direction, and adjust how pronounced the curve is.
Setting up
No animation library required. This is SVG, refs, and requestAnimationFrame.
import { useRef, useEffect, useState, useMemo, useId, FC, PointerEvent } from 'react';Building the component
The path shape is a quadratic bezier curve. The curveAmount prop controls how far the midpoint dips, which determines the depth of the wave.
const pathD = `M-100,40 Q500,${40 + curveAmount} 1540,40`;Before rendering visible text, we measure the length of one text repetition using a hidden <text> element. This length becomes the unit of wrapping for the seamless loop.
const measureRef = useRef<SVGTextElement | null>(null);
const [spacing, setSpacing] = useState(0);
useEffect(() => {
if (measureRef.current)
setSpacing(measureRef.current.getComputedTextLength());
}, [text, className]);Once we know the spacing, we fill the viewport width with enough copies of the text.
const totalText = textLength
? Array(Math.ceil(1800 / textLength) + 2).fill(text).join('')
: text;The animation loop runs in requestAnimationFrame. Each frame, it increments the startOffset attribute on the <textPath> element. When the offset wraps past the loop boundary, it resets seamlessly.
useEffect(() => {
if (!spacing || !ready) return;
let frame = 0;
const step = () => {
if (!dragRef.current && textPathRef.current) {
const delta = dirRef.current === 'right' ? speed : -speed;
const currentOffset = parseFloat(textPathRef.current.getAttribute('startOffset') || '0');
let newOffset = currentOffset + delta;
if (newOffset <= -spacing) newOffset += spacing;
if (newOffset > 0) newOffset -= spacing;
textPathRef.current.setAttribute('startOffset', newOffset + 'px');
}
frame = requestAnimationFrame(step);
};
frame = requestAnimationFrame(step);
return () => cancelAnimationFrame(frame);
}, [spacing, speed, ready]);Pointer events let users drag the text. We track velocity on pointer move so the text continues in the dragged direction after release.
const onPointerMove = (e: PointerEvent) => {
if (!interactive || !dragRef.current || !textPathRef.current) return;
const dx = e.clientX - lastXRef.current;
lastXRef.current = e.clientX;
velRef.current = dx;
// update startOffset by dx
};
const endDrag = () => {
if (!interactive) return;
dragRef.current = false;
dirRef.current = velRef.current > 0 ? 'right' : 'left';
};How to use it
<CurvedLoop
marqueeText="DESIGN BUILD SHIP "
speed={2}
curveAmount={400}
direction="left"
interactive={true}
/>A trailing space in marqueeText keeps words from running together when the text loops.
Key takeaways
- Measuring text length with
getComputedTextLength()before starting the animation ensures the loop wraps correctly regardless of font size or family. - Mutating the SVG attribute directly in the animation loop (rather than through React state) keeps rendering fast and avoids reconciliation overhead.
- The velocity-based direction change after a drag makes the interaction feel physically grounded rather than snapping back to a fixed state.