Dirck Mulder
Blocks||3 min read

Create a Marquee Along SVG Path in React

Animate content along any curved SVG path, turning logos, text, or icons into a flowing marquee that follows the arc you define.

A straight marquee is fine. A marquee that follows a curve is something you actually remember. MarqueeAlongSvgPath lets you define any SVG path, then sends your content flowing along it in a smooth, continuous loop with scroll-awareness, drag support, and configurable easing.

The final result

What we are building

A component that distributes child elements evenly along a custom SVG path and animates them so they travel along the curve. It uses the CSS offset-path property to position elements and Framer Motion's useAnimationFrame to drive the offset. Optional scroll velocity and drag input feed directly into the animation speed.

Setting up

bash
npm install motion
tsx
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useScroll,
  useSpring,
  useTransform,
  useVelocity,
} from 'motion/react';

Building the component

The core data structure multiplies children across a repeat count to fill the path. Each item gets an itemIndex that positions it evenly:

tsx
const items = React.useMemo(() => {
  const childrenArray = React.Children.toArray(children);
  return childrenArray.flatMap((child, childIndex) =>
    Array.from({ length: repeat }, (_, repeatIndex) => {
      const itemIndex = repeatIndex * childrenArray.length + childIndex;
      const key = `${childIndex}-${repeatIndex}`;
      return { child, childIndex, repeatIndex, itemIndex, key };
    })
  );
}, [children, repeat]);

With repeat={3} and three children, you get nine items total. This gives the loop enough density to fill the path without any visible gaps as items wrap around.

Each item becomes a MarqueeItem component. The item's offset distance is a derived value from the shared baseOffset motion value:

tsx
const itemOffset = useTransform(baseOffset, v => {
  const position = (itemIndex * 100) / totalItems;
  const wrappedValue = wrap(0, 100, v + position);
  return `${easing ? easing(wrappedValue / 100) * 100 : wrappedValue}%`;
});

The wrap utility ensures the value wraps cleanly between 0 and 100 percent. The easing callback lets you map the linear progress to a non-linear position, which creates interesting clustering effects.

The CSS offset-path property handles the actual curve positioning:

tsx
<motion.div
  style={{
    offsetPath: `path('${path}')`,
    offsetDistance: itemOffset,
    willChange: 'offset-distance',
  }}
>
  {child}
</motion.div>

The browser automatically handles tangent rotation when offset-rotate is not overridden, so elements face the direction of travel along the curve.

The animation loop uses useAnimationFrame to advance baseOffset each frame:

tsx
useAnimationFrame((_, delta) => {
  let moveBy =
    directionFactor.current *
    baseVelocity *
    (delta / 1000) *
    smoothHoverFactor.get();

  if (scrollAwareDirection && !isDragging.current) {
    if (velocityFactor.get() < 0) directionFactor.current = -1;
    else if (velocityFactor.get() > 0) directionFactor.current = 1;
  }

  moveBy += directionFactor.current * moveBy * velocityFactor.get();
  baseOffset.set(baseOffset.get() + moveBy);
});

Delta time (delta / 1000) normalizes the speed so it is consistent regardless of frame rate. Multiplying by velocityFactor from a scroll spring adds natural acceleration when the page is scrolled quickly.

How to use it

tsx
<MarqueeAlongSvgPath
  path="M 10 80 Q 95 10 180 80 T 350 80"
  viewBox="0 0 360 100"
  width="100%"
  height={100}
  baseVelocity={5}
  slowdownOnHover
  repeat={4}
>
  <span className="px-4 py-1 bg-neutral-800 rounded-full text-sm">React</span>
  <span className="px-4 py-1 bg-neutral-800 rounded-full text-sm">TypeScript</span>
  <span className="px-4 py-1 bg-neutral-800 rounded-full text-sm">WebGL</span>
</MarqueeAlongSvgPath>

Set showPath={true} during development to see the path your elements are following.

Key takeaways

  • CSS offset-path with a path string is all you need for curved element motion. No trigonometry in JavaScript.
  • Multiplying children across a repeat count is simpler than computing path length and filling it dynamically.
  • Delta-time normalization (delta / 1000) keeps the animation speed consistent whether the browser is running at 30fps or 120fps.