Feature cards inside a smooth-scroll viewport rotate in 3D as they enter from below, settle perfectly flat at the viewport center, and tilt away again as they leave the top, with scale, depth shadow and glow tied to scroll position.
Preview
Customize
9 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add scroll-tilt-cardsWrites components/ui/scroll-tilt-cards.tsx and components/styles/scroll-tilt-cards.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { ScrollTiltCards } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <ScrollTiltCards />6 );7}Props
Accessibility
The viewport is a focusable role="region" labelled by the title, so keyboard users can Tab to it and scroll with arrows/Page keys/Space (native) with a 2px :focus-visible outline. Wheel input is smoothed only while the viewport can still move and is released at the top/bottom edge so the page keeps scrolling; touch and scrollbar dragging stay native. Cards are a list with real text; glow and grid layers are aria-hidden. Under prefers-reduced-motion the wheel is not intercepted and cards render flat with no rotation, scale or fade.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import styles from "../styles/scroll-tilt-cards.module.css";5 6interface SmoothScrollState {7 /** Current scrollTop of the viewport in px */8 scroll: number;9 /** Maximum scrollTop in px */10 limit: number;11 /** scroll / limit, 0..1 */12 progress: number;13}14 15interface SmoothScrollOptions {16 lerp: number;17 wheelMultiplier: number;18 smooth: boolean;19 onScroll?: (state: SmoothScrollState) => void;20}21 22const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v));23 24function usePrefersReducedMotion(): boolean {25 const [reduced, setReduced] = React.useState(false);26 React.useEffect(() => {27 const mq = window.matchMedia("(prefers-reduced-motion: reduce)");28 const update = () => setReduced(mq.matches);29 update();30 mq.addEventListener("change", update);31 return () => mq.removeEventListener("change", update);32 }, []);33 return reduced;34}35 36/** True when a nested scrollable element under the pointer can still scroll in the wheel direction. */37function nestedCanScroll(node: EventTarget | null, root: HTMLElement, dy: number): boolean {38 let el: Element | null = node instanceof Element ? node : null;39 while (el && el !== root) {40 if (el instanceof HTMLElement) {41 const overflowY = window.getComputedStyle(el).overflowY;42 if ((overflowY === "auto" || overflowY === "scroll") && el.scrollHeight > el.clientHeight + 1) {43 if (dy < 0 ? el.scrollTop > 0 : el.scrollTop + el.clientHeight < el.scrollHeight - 1) return true;44 }45 }46 el = el.parentElement;47 }48 return false;49}50 51/**52 * Self-contained smooth scroller for an overflow container: wheel input sets a target and a rAF53 * loop eases scrollTop toward it. Touch, keyboard and scrollbar stay native; wheel is released at54 * the edges so the page keeps scrolling.55 */56function useSmoothScroll(57 containerRef: React.RefObject<HTMLElement | null>,58 { lerp, wheelMultiplier, smooth, onScroll }: SmoothScrollOptions,59) {60 const callbackRef = React.useRef(onScroll);61 React.useEffect(() => {62 callbackRef.current = onScroll;63 }, [onScroll]);64 65 React.useEffect(() => {66 const el = containerRef.current;67 if (!el) return;68 const ease = clamp(Number.isFinite(lerp) ? lerp : 0.1, 0.01, 1);69 const multiplier = Number.isFinite(wheelMultiplier) ? wheelMultiplier : 1;70 let target = el.scrollTop;71 let current = target;72 let raf = 0;73 let lastTime = 0;74 const getLimit = () => Math.max(0, el.scrollHeight - el.clientHeight);75 const emit = () => {76 const limit = getLimit();77 const scroll = clamp(el.scrollTop, 0, limit);78 callbackRef.current?.({ scroll, limit, progress: limit > 0 ? scroll / limit : 0 });79 };80 const tick = (time: number) => {81 const dt = lastTime ? Math.min(64, Math.max(1, time - lastTime)) : 16.667;82 lastTime = time;83 target = clamp(target, 0, getLimit());84 if (Math.abs(el.scrollTop - current) > 2) {85 current = target = el.scrollTop;86 raf = 0;87 lastTime = 0;88 emit();89 return;90 }91 current += (target - current) * (1 - Math.pow(1 - ease, dt / 16.667));92 if (Math.abs(target - current) < 0.35) current = target;93 el.scrollTop = current;94 emit();95 if (current === target) {96 raf = 0;97 lastTime = 0;98 return;99 }100 raf = requestAnimationFrame(tick);101 };102 const onWheel = (event: WheelEvent) => {103 if (event.ctrlKey || event.defaultPrevented || Math.abs(event.deltaX) > Math.abs(event.deltaY)) return;104 let dy = event.deltaY;105 if (event.deltaMode === 1) dy *= 16;106 else if (event.deltaMode === 2) dy *= el.clientHeight;107 dy *= multiplier;108 if (dy === 0 || nestedCanScroll(event.target, el, dy)) return;109 const limit = getLimit();110 if (!raf) current = target = el.scrollTop;111 if ((dy < 0 && target <= 0) || (dy > 0 && target >= limit - 0.5)) return;112 event.preventDefault();113 target = clamp(target + dy, 0, limit);114 if (!raf) {115 lastTime = 0;116 raf = requestAnimationFrame(tick);117 }118 };119 const onNativeScroll = () => {120 if (raf) return;121 current = target = el.scrollTop;122 emit();123 };124 if (smooth) el.addEventListener("wheel", onWheel, { passive: false });125 el.addEventListener("scroll", onNativeScroll, { passive: true });126 const ro = new ResizeObserver(emit);127 ro.observe(el);128 if (el.firstElementChild) ro.observe(el.firstElementChild);129 emit();130 return () => {131 if (smooth) el.removeEventListener("wheel", onWheel);132 el.removeEventListener("scroll", onNativeScroll);133 ro.disconnect();134 if (raf) cancelAnimationFrame(raf);135 };136 }, [containerRef, lerp, wheelMultiplier, smooth]);137}138 139export interface ScrollTiltCardsProps {140 /** Card titles, one tilting card per item */141 items?: string[];142 /** Heading shown above the cards */143 title?: string;144 /** Maximum rotation in degrees while a card enters or leaves */145 tiltAngle?: number;146 /** CSS perspective distance in px (smaller = more dramatic) */147 perspective?: number;148 /** Rotation axis: "x" tips cards forward/back, "y" swings them sideways */149 axis?: "x" | "y";150 /** Accent color for card glow and details */151 accentColor?: string;152 /** Viewport height in px */153 height?: number;154 /** Viewport corner radius in px */155 borderRadius?: number;156 /** Interpolation factor per 60fps frame (0.05 = floaty, 0.3 = snappy) */157 lerp?: number;158 /** Multiplier applied to wheel / trackpad deltas */159 wheelMultiplier?: number;160 /** Enable smooth wheel scrolling (native scrolling when false or under reduced motion) */161 smooth?: boolean;162 /** Optional className merged onto the root */163 className?: string;164 /** Inline styles merged onto the root */165 style?: React.CSSProperties;166}167 168const DEFAULT_ITEMS = ["Realtime sync", "Edge caching", "Typed APIs", "Audit trails", "Instant rollbacks", "Usage insights"];169const BLURBS = [170 "Every change lands on every device in under 80 ms.",171 "Static and dynamic responses served from 40+ regions.",172 "Schemas flow end to end, from database to UI.",173 "Know who changed what, and exactly when it happened.",174 "Ship boldly. Revert any deploy with a single click.",175 "Live dashboards that explain where the time goes.",176];177 178export function ScrollTiltCards({179 items = DEFAULT_ITEMS,180 title = "Tilted into view",181 tiltAngle = 38,182 perspective = 900,183 axis = "x",184 accentColor = "#38bdf8",185 height = 380,186 borderRadius = 20,187 lerp = 0.1,188 wheelMultiplier = 1,189 smooth = true,190 className = "",191 style,192}: ScrollTiltCardsProps) {193 const reduced = usePrefersReducedMotion();194 const viewportRef = React.useRef<HTMLDivElement>(null);195 const cardRefs = React.useRef<(HTMLLIElement | null)[]>([]);196 const scrollRef = React.useRef(0);197 198 const apply = React.useCallback(() => {199 const vp = viewportRef.current;200 if (!vp) return;201 const center = scrollRef.current + vp.clientHeight / 2;202 for (const card of cardRefs.current) {203 if (!card) continue;204 const mid = card.offsetTop + card.offsetHeight / 2;205 const range = vp.clientHeight / 2 + card.offsetHeight / 2;206 const p = clamp((mid - center) / Math.max(1, range), -1, 1);207 card.style.setProperty("--mf-stc-p", p.toFixed(4));208 card.style.setProperty("--mf-stc-a", Math.abs(p).toFixed(4));209 }210 }, []);211 212 const handleScroll = React.useCallback(213 (state: SmoothScrollState) => {214 scrollRef.current = state.scroll;215 apply();216 },217 [apply],218 );219 220 useSmoothScroll(viewportRef, { lerp, wheelMultiplier, smooth: smooth && !reduced, onScroll: handleScroll });221 222 React.useEffect(() => {223 apply();224 }, [apply, items, height]);225 226 const vars = {227 "--mf-stc-h": `${height}px`,228 "--mf-stc-r": `${borderRadius}px`,229 "--mf-stc-accent": accentColor,230 "--mf-stc-tilt": reduced ? 0 : tiltAngle,231 "--mf-stc-persp": `${perspective}px`,232 "--mf-stc-card-h": `${Math.round(height * 0.5)}px`,233 ...style,234 } as React.CSSProperties;235 236 return (237 <div style={vars} className={`${styles.root} ${className}`.trim()} data-axis={axis}>238 <div ref={viewportRef} className={styles.viewport} tabIndex={0} role="region" aria-label={title}>239 <div className={styles.content}>240 <header className={styles.intro}>241 <span className={styles.kicker}>Scroll Tilt</span>242 <h3 className={styles.title}>{title}</h3>243 <p className={styles.subtitle}>Cards tip in, settle flat at center, then tilt away.</p>244 </header>245 <ul className={styles.list}>246 {items.map((item, i) => (247 <li248 key={`${item}-${i}`}249 ref={(node) => {250 cardRefs.current[i] = node;251 }}252 className={styles.card}253 style={{ "--mf-stc-i": i } as React.CSSProperties}254 >255 <div className={styles.face}>256 <span aria-hidden="true" className={styles.glow} />257 <span aria-hidden="true" className={styles.grid} />258 <span className={styles.index}>{String(i + 1).padStart(2, "0")}</span>259 <span className={styles.cardTitle}>{item}</span>260 <span className={styles.blurb}>{BLURBS[i % BLURBS.length]}</span>261 </div>262 </li>263 ))}264 </ul>265 <p className={styles.outro}>Flat when it matters, dimensional in between.</p>266 </div>267 </div>268 </div>269 );270}