Two rows of very large uppercase text on a yellow band, one solid and one outlined, drifting in opposite directions. When the page is scrolled the rows pick up speed in proportion to how fast the reader is scrolling, lean into the movement, and turn around when the scroll direction changes. A short heading sits above and a sentence with two buttons below. In the preview a burst of speed plays every few seconds so the effect can be seen without scrolling. On phones the text is smaller and the rest stacks.
// Adapted from Magic UI (https://magicui.design/docs/components/scroll-based-velocity)
// Copyright (c) Magic UI. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import { motion, useAnimationFrame, useInView, useMotionValue, useReducedMotion, useScroll, useSpring, useTransform, useVelocity } from "framer-motion";
import type { MotionValue } from "framer-motion";
const rowOne = ["Open studios", "Live printing", "Glass blowing", "Clay throwing"];
const rowTwo = ["Forty makers", "Three days", "Free entry", "Bring the kids"];
/** Percent of one copy travelled per second when the page is still. */
const BASE_SPEED = 3.2;
/** How strongly scrolling speeds the rows up. */
const SCROLL_BOOST = 5;
const wrap = (min: number, max: number, v: number) => {
const range = max - min;
return ((((v - min) % range) + range) % range) + min;
};
function VelocityRow({ items, direction, factor, outline = false }: { items: string[]; direction: 1 | -1; factor: MotionValue<number>; outline?: boolean }) {
const reduce = useReducedMotion();
const ref = useRef<HTMLDivElement>(null);
const inView = useInView(ref);
const baseX = useMotionValue(direction === 1 ? -12 : -4);
const heading = useRef<number>(direction);
// Four copies sit side by side, so sliding by a quarter of the track lands on an identical frame.
const x = useTransform(baseX, (v) => `${wrap(-25, 0, v)}%`);
const skew = useTransform(factor, [-SCROLL_BOOST, SCROLL_BOOST], [direction * 9, direction * -9]);
useAnimationFrame((_, delta) => {
if (reduce || !inView) return;
const f = factor.get();
// Scrolling the other way turns the row around, as if the page were dragging it.
if (f < -0.05) heading.current = -direction;
else if (f > 0.05) heading.current = direction;
const step = heading.current * BASE_SPEED * (Math.min(delta, 60) / 1000);
baseX.set(baseX.get() + step + step * Math.abs(f) * 1.6);
});
return (
<div ref={ref} className="overflow-hidden py-1">
<motion.div aria-hidden="true" className="flex w-max whitespace-nowrap will-change-transform" style={{ x, skewX: skew }}>
{[0, 1, 2, 3].map((copy) => (
<span key={copy} className="flex shrink-0 items-center">
{items.map((item) => (
<span key={item} className="flex items-center">
<span className={`px-5 text-6xl font-black uppercase leading-none sm:px-8 sm:text-8xl lg:text-9xl ${outline ? "tracking-wide text-yellow-300 [-webkit-text-stroke:4px_#0a0a0a] [paint-order:stroke_fill]" : "tracking-tighter text-neutral-950"}`}>{item}</span>
<span className={`h-5 w-5 shrink-0 rotate-45 sm:h-8 sm:w-8 ${outline ? "border-2 border-neutral-950" : "bg-neutral-950"}`} />
</span>
))}
</span>
))}
</motion.div>
</div>
);
}
export default function ScrollVelocityMarqueeTextRows() {
const reduce = useReducedMotion();
const { scrollY } = useScroll();
const velocity = useVelocity(scrollY);
const smooth = useSpring(velocity, { damping: 50, stiffness: 400 });
// The demo's own "gust": a burst of speed every few seconds so the effect shows without scrolling.
const gustTarget = useMotionValue(0);
const gust = useSpring(gustTarget, { damping: 18, stiffness: 60 });
const factor = useTransform([smooth, gust], ([v, g]: number[]) => {
const fromScroll = Math.max(-SCROLL_BOOST, Math.min(SCROLL_BOOST, (v / 1000) * SCROLL_BOOST));
return Math.max(-SCROLL_BOOST, Math.min(SCROLL_BOOST, fromScroll + g));
});
useEffect(() => {
if (reduce) return;
let n = 0;
let off = 0;
const id = window.setInterval(() => {
n++;
gustTarget.set(n % 2 ? 3.4 : -3.4);
off = window.setTimeout(() => gustTarget.set(0), 900);
}, 3200);
const first = window.setTimeout(() => { gustTarget.set(3.4); off = window.setTimeout(() => gustTarget.set(0), 900); }, 500);
return () => {
window.clearInterval(id);
window.clearTimeout(first);
window.clearTimeout(off);
};
}, [reduce, gustTarget]);
return (
<section className="overflow-hidden bg-yellow-300 py-14 text-neutral-950 sm:py-20">
<div className="mx-auto flex max-w-6xl flex-col gap-4 px-4 sm:flex-row sm:items-end sm:justify-between sm:px-6">
<div>
<p className="font-mono text-sm font-semibold uppercase tracking-widest">Kiln Street Makers Weekend</p>
<h2 className="mt-2 max-w-xl text-3xl font-bold tracking-tight sm:text-4xl">Forty workshops throw their doors open, 14 to 16 June</h2>
</div>
<p className="font-mono text-sm font-medium">Scroll the page. The rows speed up with you.</p>
</div>
<p className="sr-only">{[...rowOne, ...rowTwo].join(". ")}.</p>
<div className="mt-10 sm:mt-14">
<VelocityRow items={rowOne} direction={-1} factor={factor} />
<VelocityRow items={rowTwo} direction={1} factor={factor} outline />
</div>
<div className="mx-auto mt-10 flex max-w-6xl flex-col items-start gap-5 px-4 sm:mt-14 sm:flex-row sm:items-center sm:justify-between sm:px-6">
<p className="max-w-md text-lg font-medium leading-snug">Watch a bowl get blown, pull your own poster, and take home something made that morning.</p>
<div className="flex flex-wrap gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-neutral-950 px-7 text-sm font-bold uppercase tracking-wide text-yellow-300 transition-transform hover:-translate-y-0.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-300">
See the map
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border-2 border-neutral-950 px-7 text-sm font-bold uppercase tracking-wide transition-colors hover:bg-neutral-950 hover:text-yellow-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-950 focus-visible:ring-offset-2 focus-visible:ring-offset-yellow-300">
Book a workshop
</a>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/scroll-velocity-marquee-text-rows.json
The page scroll position is turned into a velocity, smoothed with a spring and clamped to a small range. On every animation frame each row moves by its base speed plus an extra amount in proportion to that value, so faster scrolling means faster text.
Each row holds four identical copies of its phrases in one line. The position is wrapped so it always stays between zero and minus a quarter of the track width. At minus a quarter the picture is the same as at zero, so the jump back cannot be seen.
When the smoothed velocity turns negative, which means scrolling up, each row flips its heading. It gives the feeling that the page is dragging the text along with it.
Yes. Pass a `container` ref to `useScroll` so the velocity comes from that element. The rest of the code stays the same.
Each row is one element moved with a transform, updated from a motion value without rendering React again. Two or three rows are fine. Very large text with heavy stroke effects costs more to paint, so avoid adding text shadows.
Adapted from Scroll Based Velocity in Magic UI, used under its open-source licence. Licences and credits
Related: Dual Row Logo Marquee with Edge Fade, Live News Ticker Bar with Pulsing Dot, Text Reveal on Scroll Word by Word.
More marquees or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.