Velocity Marquee

Infinite marquees that speed up, skew and reverse with your scroll velocity — and ease to a stop on hover.

  • Scroll & Hover Effects
  • Sports
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Reduced motion shows a static, scrollable row.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/marquee-velocity.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/effects/marquee-velocity.tsx
1/**
2 * VelocityMarquee — an infinite marquee whose speed and skew react to scroll
3 * velocity. Scrolling down speeds it up in its own direction, scrolling up
4 * reverses it briefly; skew follows the velocity on a spring. Hover (or
5 * keyboard focus) eases the row to a stop.
6 *
7 * Props:
8 * - children: the row content (rendered twice for a seamless loop).
9 * - speed: base px/s (default 60). direction: 1 (left) | -1 (right).
10 * - boost: how much scroll velocity adds (default 0.9).
11 * - maxSkew: degrees at full velocity (default 8).
12 * - pauseOnHover (default true).
13 * - scrollContainer: optional ref; otherwise nearest scrollable ancestor.
14 * CSS vars: --fd-mqv-gap.
15 *
16 * Browser support: evergreen. One rAF loop per marquee, paused off-screen and
17 * when the tab is hidden.
18 * Accessibility: the duplicate copy is aria-hidden; the row is a region with
19 * an aria-label. Reduced motion: static row, no auto-play.
20 */
21import { useEffect, useRef } from "react";
22import type { CSSProperties, ReactNode, RefObject } from "react";
23
24export interface VelocityMarqueeProps {
25 children: ReactNode;
26 speed?: number;
27 direction?: 1 | -1;
28 boost?: number;
29 maxSkew?: number;
30 pauseOnHover?: boolean;
31 label?: string;
32 scrollContainer?: RefObject<HTMLElement | null>;
33 className?: string;
34 style?: CSSProperties;
35}
36
37function findScrollParent(el: HTMLElement): HTMLElement | null {
38 let p = el.parentElement;
39 while (p && p !== document.body && p !== document.documentElement) {
40 const oy = getComputedStyle(p).overflowY;
41 if ((oy === "auto" || oy === "scroll" || oy === "overlay") && p.scrollHeight > p.clientHeight + 1) return p;
42 p = p.parentElement;
43 }
44 return null;
45}
46
47export function VelocityMarquee({
48 children,
49 speed = 60,
50 direction = 1,
51 boost = 0.9,
52 maxSkew = 8,
53 pauseOnHover = true,
54 label = "Marquee",
55 scrollContainer,
56 className,
57 style,
58}: VelocityMarqueeProps) {
59 const root = useRef<HTMLDivElement>(null);
60 const track = useRef<HTMLDivElement>(null);
61 const first = useRef<HTMLDivElement>(null);
62
63 useEffect(() => {
64 const el = root.current;
65 const tr = track.current;
66 const one = first.current;
67 if (!el || !tr || !one) return;
68 if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) {
69 el.dataset.reduced = "";
70 return;
71 }
72 const sc = scrollContainer?.current ?? findScrollParent(el);
73 const target: HTMLElement | Window = sc ?? window;
74 const readY = () => (sc ? sc.scrollTop : window.scrollY);
75
76 let w = one.offsetWidth;
77 let x = 0;
78 let lastY = readY();
79 let vel = 0; // smoothed scroll velocity, px/s
80 let dir = 1; // flips with scroll direction
81 let hold = 1; // 1 = running, 0 = paused (hover)
82 let holdGoal = 1;
83 let skew = 0;
84 let skewV = 0;
85 let raf = 0;
86 let last = 0;
87 let visible = true;
88
89 const tick = (t: number) => {
90 const dt = Math.min(0.05, (t - last) / 1000 || 0.016);
91 last = t;
92 const y = readY();
93 const inst = (y - lastY) / dt;
94 lastY = y;
95 vel += (inst - vel) * (1 - Math.exp(-dt * 10));
96 if (Math.abs(vel) > 40) dir = vel > 0 ? 1 : -1;
97 hold += (holdGoal - hold) * (1 - Math.exp(-dt * 8));
98 const v = (speed + Math.abs(vel) * boost) * dir * direction * hold;
99 x -= v * dt;
100 if (w > 0) {
101 if (x <= -w) x += w;
102 else if (x > 0) x -= w;
103 }
104 // skew on a spring toward a velocity-derived target
105 const goal = Math.max(-maxSkew, Math.min(maxSkew, (-vel / 1300) * maxSkew * direction)) * hold;
106 for (let i = 0; i < 2; i++) {
107 const h = dt / 2;
108 skewV += (-380 * (skew - goal) - 30 * skewV) * h;
109 skew += skewV * h;
110 }
111 tr.style.transform = `translate3d(${x.toFixed(2)}px,0,0) skewX(${skew.toFixed(3)}deg)`;
112 raf = visible && !document.hidden ? requestAnimationFrame(tick) : 0;
113 };
114 const start = () => {
115 if (!raf && visible && !document.hidden) {
116 last = performance.now();
117 lastY = readY();
118 raf = requestAnimationFrame(tick);
119 }
120 };
121 const io = new IntersectionObserver(([e]) => {
122 visible = e.isIntersecting;
123 if (visible) start();
124 }, { root: sc ?? null });
125 io.observe(el);
126 const ro = new ResizeObserver(() => {
127 w = one.offsetWidth;
128 });
129 ro.observe(one);
130 const onVis = () => start();
131 const enter = () => {
132 if (pauseOnHover) holdGoal = 0;
133 };
134 const leave = () => {
135 holdGoal = 1;
136 };
137 const onScroll = () => start();
138 el.addEventListener("pointerenter", enter);
139 el.addEventListener("pointerleave", leave);
140 el.addEventListener("focusin", enter);
141 el.addEventListener("focusout", leave);
142 target.addEventListener("scroll", onScroll, { passive: true });
143 document.addEventListener("visibilitychange", onVis);
144 start();
145 return () => {
146 io.disconnect();
147 ro.disconnect();
148 el.removeEventListener("pointerenter", enter);
149 el.removeEventListener("pointerleave", leave);
150 el.removeEventListener("focusin", enter);
151 el.removeEventListener("focusout", leave);
152 target.removeEventListener("scroll", onScroll);
153 document.removeEventListener("visibilitychange", onVis);
154 if (raf) cancelAnimationFrame(raf);
155 };
156 }, [speed, direction, boost, maxSkew, pauseOnHover, scrollContainer]);
157
158 return (
159 <div ref={root} className={`fd-mqv${className ? " " + className : ""}`} role="region" aria-label={label} style={style}>
160 <style>{css}</style>
161 <div ref={track} className="fd-mqv-track">
162 <div ref={first} className="fd-mqv-set">
163 {children}
164 </div>
165 <div className="fd-mqv-set" aria-hidden="true">
166 {children}
167 </div>
168 <div className="fd-mqv-set" aria-hidden="true">
169 {children}
170 </div>
171 </div>
172 </div>
173 );
174}
175
176const css = `
177.fd-mqv{--fd-mqv-gap:0px;position:relative;display:block;width:100%;margin:0;padding:0;overflow:hidden}
178.fd-mqv,.fd-mqv *{box-sizing:border-box}
179.fd-mqv-track{display:flex;width:max-content;margin:0;padding:0;will-change:transform;backface-visibility:hidden}
180.fd-mqv-set{display:flex;flex:none;align-items:center;gap:var(--fd-mqv-gap);margin:0;padding:0 var(--fd-mqv-gap) 0 0}
181.fd-mqv[data-reduced] .fd-mqv-set + .fd-mqv-set{display:none}
182.fd-mqv[data-reduced]{overflow-x:auto}
183`;
184
185/* ------------------------------------------------------------------ Demo */
186
187function Spark({ color }: { color: string }) {
188 return (
189 <svg className="fd-mqv-demo-spark" viewBox="0 0 40 40" aria-hidden="true">
190 <path d="M20 0 L 24 16 L 40 20 L 24 24 L 20 40 L 16 24 L 0 20 L 16 16 Z" fill={color} />
191 </svg>
192 );
193}
194
195const SHOES = [
196 { name: "Volt 3", tag: "Road · 212 g", c1: "#D7FF3A", c2: "#0E0E10" },
197 { name: "Apex Trail", tag: "Trail · 268 g", c1: "#FF5A1F", c2: "#2B1A10" },
198 { name: "Tempo Lite", tag: "Track · 168 g", c1: "#3AB4FF", c2: "#0A1A2B" },
199 { name: "Long Run", tag: "Marathon · 230 g", c1: "#F2F2F2", c2: "#1A1A1A" },
200 { name: "Pace Max", tag: "Daily · 245 g", c1: "#FF3A7A", c2: "#2B0A16" },
201];
202
203function Shoe({ c1, c2 }: { c1: string; c2: string }) {
204 return (
205 <svg viewBox="0 0 220 110" className="fd-mqv-demo-shoe" aria-hidden="true">
206 {/* upper */}
207 <path d="M16 74 C 16 60 28 54 44 52 C 64 48 76 30 92 22 C 98 19 104 20 108 24 L 118 34 C 140 46 176 50 198 62 C 210 69 210 80 202 84 L 24 88 C 18 88 16 82 16 74 Z" fill={c1} />
208 {/* heel counter + collar */}
209 <path d="M16 74 C 16 60 28 54 44 52 C 40 62 38 72 40 86 L 24 88 C 18 88 16 82 16 74 Z" fill={c2} opacity=".35" />
210 <path d="M92 22 C 98 19 104 20 108 24 L 118 34 C 112 34 100 30 92 22 Z" fill={c2} opacity=".5" />
211 {/* laces */}
212 <path d="M96 34 L 110 40 M102 42 L 118 46 M110 50 L 126 52 M118 56 L 134 58" stroke="#fff" strokeWidth="3" strokeLinecap="round" opacity=".85" />
213 {/* swoosh-free brand stripe */}
214 <path d="M50 70 C 90 66 130 58 168 64" stroke={c2} strokeWidth="5" strokeLinecap="round" fill="none" opacity=".45" />
215 {/* midsole + outsole */}
216 <path d="M14 80 L 204 84 C 212 86 212 96 204 98 L 28 100 C 16 100 10 90 14 80 Z" fill="#F4F4F2" />
217 <path d="M18 96 L 204 96 C 206 100 202 104 196 104 L 30 104 C 22 104 18 100 18 96 Z" fill={c2} />
218 </svg>
219 );
220}
221
222export function Demo() {
223 return (
224 <div className="fd-mqv-demo">
225 <style>{demoCss}</style>
226 <header className="fd-mqv-demo-hero">
227 <div className="fd-mqv-demo-top">
228 <span className="fd-mqv-demo-logo">STRIDE/9</span>
229 <span className="fd-mqv-demo-drop">Drop 04 · Autumn 2026</span>
230 </div>
231 <h1 className="fd-mqv-demo-h">
232 Built for
233 <br />
234 the last mile.
235 </h1>
236 <p className="fd-mqv-demo-cue" aria-hidden="true">
237 <span className="fd-mqv-demo-cue-line" />
238 Scroll fast — the type keeps up
239 </p>
240 </header>
241
242 <section className="fd-mqv-demo-band">
243 <VelocityMarquee label="Stride/9 slogans" speed={70} direction={1}>
244 {["Run further", "Recover faster", "Race lighter", "Carbon plate", "Zero excuses"].map((w, i) => (
245 <span key={w} className="fd-mqv-demo-word" data-alt={i % 2 ? "" : undefined}>
246 {w}
247 <Spark color={i % 2 ? "#D7FF3A" : "#0E0E10"} />
248 </span>
249 ))}
250 </VelocityMarquee>
251 <VelocityMarquee label="Stride/9 drop 04 products" speed={50} direction={-1} className="fd-mqv-demo-row2">
252 {SHOES.map((s) => (
253 <a key={s.name} className="fd-mqv-demo-card" href="#shop" style={{ ["--c" as string]: s.c1 }}>
254 <Shoe c1={s.c1} c2={s.c2} />
255 <span className="fd-mqv-demo-card-n">{s.name}</span>
256 <span className="fd-mqv-demo-card-t">{s.tag}</span>
257 </a>
258 ))}
259 </VelocityMarquee>
260 </section>
261
262 <section className="fd-mqv-demo-copy">
263 <p className="fd-mqv-demo-p">
264 Hover a row to stop it. Scroll up and the rows run backwards for a beat. Drop 04 ships 12 November — members get 48 hours early.
265 </p>
266 </section>
267 </div>
268 );
269}
270
271const demoCss = `
272.fd-mqv-demo{display:block;margin:0;padding:0;background:#F1F0EC;color:#0E0E10;min-height:100vh;overflow-x:clip}
273.fd-mqv-demo,.fd-mqv-demo *{box-sizing:border-box}
274.fd-mqv-demo-hero{display:flex;flex-direction:column;min-height:44vh;max-width:1280px;margin:0 auto;padding:28px clamp(16px,4vw,56px) 40px}
275.fd-mqv-demo-top{display:flex;justify-content:space-between;align-items:center;gap:16px}
276.fd-mqv-demo-logo{color:#0E0E10;font:800 1.0625rem/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.02em}
277.fd-mqv-demo-drop{color:#55554F;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.04em;text-transform:uppercase}
278.fd-mqv-demo-h{margin:auto 0 0;padding:0;color:#0E0E10;font:800 clamp(3rem,9vw,7.5rem)/.88 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.055em;text-transform:uppercase}
279.fd-mqv-demo-cue{display:inline-flex;align-items:center;gap:10px;margin:24px 0 0;padding:0;color:#55554F;font:500 0.75rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:.08em;text-transform:uppercase}
280.fd-mqv-demo-cue-line{position:relative;display:block;width:1px;height:28px;overflow:hidden;background:rgba(14,14,16,.15)}
281.fd-mqv-demo-cue-line::after{content:"";position:absolute;left:0;top:0;width:1px;height:12px;background:#0E0E10;animation:fd-mqv-cue 1.6s cubic-bezier(0.65,0,0.35,1) infinite}
282@keyframes fd-mqv-cue{0%{transform:translateY(-12px)}100%{transform:translateY(28px)}}
283.fd-mqv-demo-band{display:grid;gap:clamp(16px,3vh,28px);margin:0;padding:clamp(20px,4vh,40px) 0;background:#0E0E10;transform:rotate(-2deg) scale(1.04);transform-origin:50% 50%}
284.fd-mqv-demo-word{
285 display:inline-flex;align-items:center;gap:clamp(16px,2.4vw,36px);padding:0 clamp(16px,2.4vw,36px) 0 0;white-space:nowrap;
286 color:#F1F0EC;font:800 clamp(3rem,8.5vw,7.5rem)/1 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.05em;text-transform:uppercase;
287}
288.fd-mqv-demo-word[data-alt]{color:transparent;-webkit-text-stroke:2px #D7FF3A}
289.fd-mqv-demo-spark{display:block;width:.42em;height:.42em;flex:none}
290.fd-mqv-demo-word:not([data-alt]) .fd-mqv-demo-spark path{fill:#D7FF3A}
291.fd-mqv-demo-row2 .fd-mqv-set{gap:14px;padding-right:14px}
292.fd-mqv-demo-card{
293 position:relative;display:grid;grid-template-columns:auto;gap:2px;width:clamp(200px,22vw,280px);margin:0;padding:14px 18px 16px;border-radius:20px;
294 background:#1C1C20;color:#F1F0EC;text-decoration:none;border:1px solid rgba(255,255,255,.06);
295 transition:background 240ms cubic-bezier(0.22,1,0.36,1),transform 240ms cubic-bezier(0.22,1,0.36,1);
296}
297.fd-mqv-demo-card:hover{background:#26262B;transform:translateY(-3px)}
298.fd-mqv-demo-card:focus-visible{outline:2px solid rgba(10,132,255,.7);outline-offset:2px}
299.fd-mqv-demo-shoe{display:block;width:100%;height:auto;margin:0 0 8px;filter:drop-shadow(0 10px 14px rgba(0,0,0,.45));transform:rotate(-6deg)}
300.fd-mqv-demo-card-n{color:#F1F0EC;font:700 1.0625rem/1.2 "SF Pro Display",ui-sans-serif,-apple-system,"Inter Display","Inter",sans-serif;letter-spacing:-0.01em}
301.fd-mqv-demo-card-t{color:rgba(241,240,236,.55);font:500 0.75rem/1.3 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace}
302.fd-mqv-demo-card::after{content:"";position:absolute;right:16px;top:16px;width:10px;height:10px;border-radius:50%;background:var(--c)}
303.fd-mqv-demo-copy{max-width:1280px;margin:0 auto;padding:clamp(48px,10vh,96px) clamp(16px,4vw,56px) 70vh}
304.fd-mqv-demo-p{margin:0;padding:0;max-width:560px;color:#55554F;font:400 1.0625rem/1.5 ui-sans-serif,-apple-system,"Inter",sans-serif}
305@media (prefers-reduced-motion: reduce){.fd-mqv-demo-cue-line::after{animation:none}}
306`;

More Scroll & Hover Effects. Same file contract.

Scroll Split Card

A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.

Hover Transitions

Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.

Sticky Storytelling

Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.