Scroll velocity marquee
Four rows of giant text run in opposite directions, speed up and lean with your scroll, then reverse when you scroll up.
Settings
Code
import { Fragment, useRef } from "react";
import {
motion,
useAnimationFrame,
useMotionValue,
useReducedMotion,
useScroll,
useSpring,
useTransform,
useVelocity,
type MotionValue,
} from "framer-motion";
interface ScrollVelocityMarqueeProps {
/** Frases separadas por "·". */
text?: string;
accent?: string;
/** Velocidad base, en % de la cinta por segundo. */
speed?: number;
/** true: caja con scroll propio. false: reacciona al scroll de la página. */
contained?: boolean;
}
type RowStyle = "solid" | "band" | "ghost";
const ROWS: { look: RowStyle; direction: 1 | -1; start: number }[] = [
{ look: "ghost", direction: 1, start: -9 },
{ look: "solid", direction: -1, start: -6 },
{ look: "band", direction: 1, start: -14 },
{ look: "ghost", direction: -1, start: -5 },
];
const wrap = (min: number, max: number, v: number) => {
const r = max - min;
return ((((v - min) % r) + r) % r) + min;
};
function Row({
items,
speed,
direction,
factor,
skew,
look,
start,
accent,
}: {
items: string[];
speed: number;
direction: 1 | -1;
start: number;
factor: MotionValue<number>;
skew: MotionValue<number>;
look: RowStyle;
accent: string;
}) {
const baseX = useMotionValue(start);
const dir = useRef<number>(direction);
const reduce = useReducedMotion();
const x = useTransform(baseX, (v) => wrap(-25, 0, v) + "%");
useAnimationFrame((_, delta) => {
if (reduce) return;
const f = factor.get();
if (f < 0) dir.current = -direction;
else if (f > 0) dir.current = direction;
baseX.set(baseX.get() + dir.current * speed * (delta / 1000) * (1 + Math.abs(f)));
});
const text =
look === "band"
? "text-[#0b0b0c]"
: look === "ghost"
? "text-[#1d1d1f]/[0.13] dark:text-[#f5f5f7]/[0.16]"
: "text-[#1d1d1f] dark:text-[#f5f5f7]";
const dot = look === "band" ? "bg-[#0b0b0c]" : "bg-current opacity-30";
return (
<div
className={"overflow-hidden whitespace-nowrap " + (look === "band" ? "py-[0.08em]" : "")}
style={look === "band" ? { background: accent } : undefined}
>
<motion.div className="flex w-max" style={{ x, skewX: skew }}>
{Array.from({ length: 4 }, (_, copy) => (
<span
key={copy}
aria-hidden={copy > 0 ? true : undefined}
className={
"flex shrink-0 items-center text-[clamp(44px,17cqi,140px)] font-semibold leading-[1.02] tracking-[-0.045em] " +
text
}
>
{items.map((item, i) => (
<Fragment key={i}>
<span className="px-[0.18em]">{item}</span>
<span className={"mx-[0.12em] h-[0.14em] w-[0.14em] shrink-0 rounded-full " + dot} />
</Fragment>
))}
</span>
))}
</motion.div>
</div>
);
}
export default function ScrollVelocityMarquee({
text = "Handmade · No templates · Ready to ship",
accent = "#d4ff3a",
speed = 3,
contained = true,
}: ScrollVelocityMarqueeProps) {
const scroller = useRef<HTMLDivElement>(null);
const { scrollY } = useScroll(contained ? { container: scroller } : {});
const velocity = useVelocity(scrollY);
const smooth = useSpring(velocity, { damping: 50, stiffness: 400 });
const factor = useTransform(smooth, [0, 1000], [0, 5], { clamp: false });
const skew = useTransform(smooth, [-2000, 2000], [-12, 12]);
const items = text
.split("·")
.map((s) => s.trim())
.filter(Boolean);
const rows = (
<div className="flex w-full flex-col gap-[1.5cqi] [container-type:inline-size]">
{ROWS.map((r, i) => (
<Row
key={i}
items={items}
speed={speed}
direction={r.direction}
start={r.start}
factor={factor}
skew={skew}
look={r.look}
accent={accent}
/>
))}
</div>
);
if (!contained) return <div className="w-full overflow-hidden py-10">{rows}</div>;
return (
<div
ref={scroller}
className="relative h-[28rem] w-full overflow-y-auto overflow-x-hidden rounded-3xl bg-[#fafafa] [scrollbar-width:none] dark:bg-[#0a0a0a]"
>
<div className="h-[500%]">
<div className="sticky top-0 flex h-[20%] items-center overflow-hidden">{rows}</div>
</div>
</div>
);
}
Prompt for AI
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Text & scroll
Where to use it
A scroll velocity marquee gives a page a pulse between sections: an agency showing what it does, a festival listing dates and cities, a fashion brand shouting its slogan. Solid rows, ghosted rows and one colour band run against each other, and the faster someone scrolls, the harder they lean and rush.
Two to four short phrases separated by “·” read better than long sentences. A bright band colour gives the block its punch, so pick it before anything else. For visitors who ask their system for less motion, the rows stay still, which keeps the text readable.