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.
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.
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.
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.
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.
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
<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
useVelocityintouseSpringintouseTransformis 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
useLayoutEffectis necessary because the content width depends on the rendered font and text, which is not known until after the first paint.