ScrollScroll Animationsno dependencies

ScrollTiltCards

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

Scroll Tilt

Tilted into view

Cards tip in, settle flat at center, then tilt away.

  • 01Realtime syncEvery change lands on every device in under 80 ms.
  • 02Edge cachingStatic and dynamic responses served from 40+ regions.
  • 03Typed APIsSchemas flow end to end, from database to UI.
  • 04Audit trailsKnow who changed what, and exactly when it happened.
  • 05Instant rollbacksShip boldly. Revert any deploy with a single click.
  • 06Usage insightsLive dashboards that explain where the time goes.

Flat when it matters, dimensional in between.

Customize

9 props
38
900
0.1
1

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add scroll-tilt-cards

Writes 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.

example.tsx
1import { ScrollTiltCards } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <ScrollTiltCards />6  );7}

Props

PropTypeDefault
items

Card titles, one tilting card per item.

string[]['Realtime sync', 'Edge caching', 'Typed APIs', 'Audit trails', 'Instant rollbacks', 'Usage insights']
title

Heading shown above the cards (also the region label).

string'Tilted into view'
tiltAngle

Maximum rotation in degrees while a card enters or leaves.

number38
perspective

CSS perspective distance in px; smaller values look more dramatic.

number900
axis

Rotation axis: x tips cards forward/back, y swings them sideways.

'x' | 'y''x'
accentColor

Accent color for card glow and index numbers.

string'#38bdf8'
height

Height of the component's own scroll viewport in px (width is always 100%).

number380
borderRadius

Corner radius of the viewport in px.

number20
lerp

Interpolation factor per 60fps frame for wheel smoothing (0.05 floaty, 0.3 snappy).

number0.1
wheelMultiplier

Multiplier applied to wheel and trackpad deltas.

number1
smooth

Enable smooth wheel scrolling; false (or prefers-reduced-motion) uses native scrolling.

booleantrue
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/scroll-tilt-cards.tsx
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}