Velocity Marquee Rail
A marquee that accelerates with scroll speed and flips direction when you scroll back.
- Category
- Animations
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Props
"use client";
import { useRef } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useScroll,
useSpring,
useTransform,
useVelocity,
wrap,
} from "framer-motion";
export function VelocityMarqueeRail({
words,
baseSpeed = 2,
velocityBoost = 4,
reverse = true,
}: {
words: string[];
baseSpeed?: number;
velocityBoost?: number;
/** Flip the rail's travel direction when the page scrolls up. */
reverse?: boolean;
}) {
const { scrollY } = useScroll();
const scrollVelocity = useVelocity(scrollY);
// Raw scroll velocity is extremely spiky; the spring is what turns it into
// something usable as an animation input.
const smoothVelocity = useSpring(scrollVelocity, { damping: 50, stiffness: 400 });
// Map px/s into a small multiplier, CLAMPED — without the clamp a fast
// flick sends the rail off at an unreadable speed.
const velocityFactor = useTransform(smoothVelocity, [0, 1000], [0, velocityBoost], {
clamp: true,
});
const baseX = useMotionValue(0);
const direction = useRef(1);
useAnimationFrame((_, delta) => {
// delta-scaled, so speed is identical on 60Hz and 120Hz displays.
let moveBy = direction.current * baseSpeed * (delta / 1000) * 60;
// Scrolling up flips the rail. Advancing position manually in a frame
// callback (rather than running a CSS animation) is the only way to
// reverse mid-flight without a visible restart.
if (reverse) {
const v = velocityFactor.get();
if (v < 0) direction.current = -1;
else if (v > 0) direction.current = 1;
}
moveBy += direction.current * moveBy * velocityFactor.get();
baseX.set(baseX.get() + moveBy);
});
// The track repeats 4x, so one tile is 25%. Wrapping over [-25, 0] loops
// seamlessly and keeps the offset bounded instead of growing forever.
const x = useTransform(baseX, (v) => wrap(-25, 0, v / 12) + "%");
return (
<div className="overflow-hidden py-4">
<motion.div style={{ x }} className="flex whitespace-nowrap will-change-transform">
{Array.from({ length: 4 }).map((_, tile) => (
<span key={tile} className="flex shrink-0 items-center" aria-hidden={tile > 0}>
{words.map((w) => (
<span key={w} className="flex items-center">
<span className="px-3 text-3xl font-black tracking-tight">{w}</span>
<span className="size-1.5 rounded-full bg-blue-500" />
</span>
))}
</span>
))}
</motion.div>
</div>
);
}
About this animation
A CSS-animated marquee cannot do this. Reversing a running CSS animation restarts it from a keyframe boundary, which shows as a visible jump, so direction has to be a property of a position you advance yourself — hence a motion value mutated inside a frame callback. Three details make it behave. Raw scroll velocity is extremely spiky, so it goes through a spring before it is used for anything. The mapping from pixels-per-second to a speed multiplier is clamped, because an unclamped trackpad flick registers thousands of px/s and sends the rail past legibility. And movement is scaled by frame delta, so the drift is the same speed on a 60Hz and a 120Hz display rather than twice as fast on the latter — a bug that is invisible on the machine most people develop on. The loop itself relies on Framer's wrap helper over a range matching one tile's share of the repeated track, which both makes the seam invisible and keeps the offset bounded instead of growing forever. The content repeats, so every duplicate tile must be hidden from assistive technology.
Curator’s note
Built for viberdy 2.0. The clamp on the velocity mapping is not optional — without it a trackpad flick makes the rail unreadable.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Scroll Velocity Skew
animationsSkews its content based on how fast the page is being scrolled, relaxing back to flat at rest.
Scroll Card Deal
animationsA pile of cards that fans out symmetrically as its section crosses the viewport.
Line Mask Reveal
animationsReveals text one line at a time, each sliding up from behind its own clipping mask.