Dirck Mulder
Text Animations||3 min read

Create a Scroll Velocity Marquee in React

Build a marquee that speeds up and slows down based on scroll velocity, creating a dynamic connection between scrolling and motion.

Most marquees scroll at a fixed pace regardless of what you do. That feels mechanical. A scroll velocity marquee responds to you: scroll fast and it races, slow down and it coasts. That responsiveness makes it feel alive in a way a static ticker never does.

The final result

ScrollVelocity renders one or more looping text rows where the scroll speed of the page directly accelerates or decelerates the marquee. Odd-indexed rows move in the opposite direction for contrast.

Setting up

This component uses Motion's scroll velocity hooks.

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

Building the component

The outer ScrollVelocity component simply renders one VelocityText per row, alternating direction based on the index.

tsx
return (
  <section>
    {texts.map((text, index) => (
      <VelocityText
        key={index}
        baseVelocity={index % 2 !== 0 ? -velocity : velocity}
        scrollContainerRef={scrollContainerRef}
      >
        {text}
      </VelocityText>
    ))}
  </section>
);

Inside VelocityText, the scroll velocity chain is the heart of the component. We get the scroll position, derive its velocity, smooth it with a spring, then transform that smoothed velocity into a speed multiplier.

tsx
const { scrollY } = useScroll(scrollOptions);
const scrollVelocity = useVelocity(scrollY);
const smoothVelocity = useSpring(scrollVelocity, {
  damping: damping ?? 50,
  stiffness: stiffness ?? 400,
});
const velocityFactor = useTransform(
  smoothVelocity,
  velocityMapping.input,
  velocityMapping.output,
  { clamp: false }
);

The useAnimationFrame loop advances the base position on every frame. The speed multiplier from scroll velocity is added on top of the base movement, so fast scrolling amplifies the marquee speed.

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

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

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

The position wraps using a custom wrap function. We measure the width of one copy of the text and wrap baseX between -copyWidth and 0, creating a seamless loop.

tsx
function wrap(min: number, max: number, v: number): number {
  const range = max - min;
  const mod = (((v - min) % range) + range) % range;
  return mod + min;
}

const x = useTransform(baseX, v => {
  if (copyWidth === 0) return '0px';
  return `${wrap(-copyWidth, 0, v)}px`;
});

We render numCopies instances of the text in a row, wide enough to fill the viewport no matter where the wrap point lands.

How to use it

tsx
<ScrollVelocity
  texts={['Design', 'Build']}
  velocity={100}
  damping={50}
  stiffness={400}
  numCopies={6}
/>

The velocityMapping prop controls the relationship between scroll speed and marquee acceleration. The default maps 0-1000px/s scroll velocity to 0-5x speed multiplier.

Key takeaways

  • Chaining useVelocity into useSpring into useTransform is the idiomatic Motion pattern for scroll-reactive animations. The spring smooths out the jagged velocity signal.
  • The direction flip logic (directionFactor.current) allows the marquee to reverse direction when the user scrolls up, which creates a satisfying elastic feel.
  • Measuring the copy width with a ref and useLayoutEffect is necessary because the content width depends on the rendered font and text, which is not known until after the first paint.