Scroll Split Card
A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Infinite marquees that speed up, skew and reverse with your scroll velocity — and ease to a stop on hover.
npx shadcn@latest add https://freedesignmd.com/r/marquee-velocity.json1/**2 * VelocityMarquee — an infinite marquee whose speed and skew react to scroll3 * velocity. Scrolling down speeds it up in its own direction, scrolling up4 * reverses it briefly; skew follows the velocity on a spring. Hover (or5 * 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 and17 * when the tab is hidden.18 * Accessibility: the duplicate copy is aria-hidden; the row is a region with19 * 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/s80 let dir = 1; // flips with scroll direction81 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 target105 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 for233 <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 up239 </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`;A pinned card splits into three panels that fan out in 3D and flip, one by one, to reveal your services.
Eight hover reveals for profile cards: morph blob, diagonal wipe, strips, pixel dissolve, iris, slices, focus and a liquid lens.
Apple-style scrollytelling: pinned media morphs between four states while text steps scroll past a progress rail.