Gravity Scroll Cards for React
Cards that stack and unstack with scroll. Gravity-style depth effect.
Component
Card 1
Card 2
Card 3
"use client";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import React, { useLayoutEffect, useRef } from "react";
import { cn } from "@/lib/utils";
gsap.registerPlugin(ScrollTrigger);
interface GravityScrollCardsProps {
children: React.ReactNode;
className?: string;
cardClassName?: string;
cardWidth?: string | number;
stackOffset?: number;
stackRotation?: number;
start?: string;
end?: string;
}
export function GravityScrollCards({
children,
className,
cardClassName,
cardWidth = 256,
stackOffset = 24,
stackRotation = 3,
start = "top 80%",
end = "top 20%",
}: GravityScrollCardsProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const section = sectionRef.current;
if (!section) {
return;
}
const cardEls = section.querySelectorAll("[data-card]");
const ctx = gsap.context(() => {
cardEls.forEach((el, i) => {
gsap.fromTo(
el,
{ rotateZ: 0, y: 0 },
{
ease: "none",
rotateZ: i * -stackRotation,
scrollTrigger: {
end,
scrub: 1,
start,
trigger: section,
},
y: i * stackOffset,
zIndex: cardEls.length - i,
},
);
});
}, section);
return () => ctx.revert();
}, [stackOffset, stackRotation, start, end]);
const count = React.Children.count(children);
return (
<div
ref={sectionRef}
className={cn("relative min-h-[320px]", className)}
>
{React.Children.map(children, (child, i) =>
child ? (
<div
key={
React.isValidElement(child) &&
child.key != null
? child.key
: `card-${i}`
}
data-card={true}
className={cn(
"absolute top-4 left-1/2 -translate-x-1/2",
cardClassName,
)}
style={{
width:
typeof cardWidth === "number"
? `${cardWidth}px`
: cardWidth,
zIndex: count - i,
}}
>
{child}
</div>
) : null,
)}
</div>
);
}Installation
1. Install dependencies
pnpm add gsap2. Copy the component file
"use client";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import React, { useLayoutEffect, useRef } from "react";
import { cn } from "@/lib/utils";
gsap.registerPlugin(ScrollTrigger);
interface GravityScrollCardsProps {
children: React.ReactNode;
className?: string;
cardClassName?: string;
cardWidth?: string | number;
stackOffset?: number;
stackRotation?: number;
start?: string;
end?: string;
}
export function GravityScrollCards({
children,
className,
cardClassName,
cardWidth = 256,
stackOffset = 24,
stackRotation = 3,
start = "top 80%",
end = "top 20%",
}: GravityScrollCardsProps) {
const sectionRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const section = sectionRef.current;
if (!section) {
return;
}
const cardEls = section.querySelectorAll("[data-card]");
const ctx = gsap.context(() => {
cardEls.forEach((el, i) => {
gsap.fromTo(
el,
{ rotateZ: 0, y: 0 },
{
ease: "none",
rotateZ: i * -stackRotation,
scrollTrigger: {
end,
scrub: 1,
start,
trigger: section,
},
y: i * stackOffset,
zIndex: cardEls.length - i,
},
);
});
}, section);
return () => ctx.revert();
}, [stackOffset, stackRotation, start, end]);
const count = React.Children.count(children);
return (
<div
ref={sectionRef}
className={cn("relative min-h-[320px]", className)}
>
{React.Children.map(children, (child, i) =>
child ? (
<div
key={
React.isValidElement(child) &&
child.key != null
? child.key
: `card-${i}`
}
data-card={true}
className={cn(
"absolute top-4 left-1/2 -translate-x-1/2",
cardClassName,
)}
style={{
width:
typeof cardWidth === "number"
? `${cardWidth}px`
: cardWidth,
zIndex: count - i,
}}
>
{child}
</div>
) : null,
)}
</div>
);
}3. Import and use
import { GravityScrollCards } from "@/components/gravity-scroll-cards";
<GravityScrollCards>{/* cards */}</GravityScrollCards>;Usage
Import
Add the GravityScrollCards import.
import { GravityScrollCards } from "@/components/gravity-scroll-cards";Use
Wrap your card elements.
<GravityScrollCards>{/* cards */}</GravityScrollCards>;Guidelines
- Pass children as cards. They stack with scroll-linked transform.
- stackOffset and stackRotation control the stacking effect.
- start and end control scroll range.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| className | string | - | Additional CSS classes. |
| cardClassName | string | - | Classes for each card wrapper. |
| cardWidth | string | number | 256 | Card width in pixels or CSS value (e.g. '16rem'). |
| stackOffset | number | 24 | Vertical offset between stacked cards (px). |
| stackRotation | number | 3 | Rotation per card in degrees. |
| start | string | "top 80%" | ScrollTrigger start. |
| end | string | "top 20%" | ScrollTrigger end. |
Accessibility
- Decorative scroll-linked visual effect that wraps arbitrary children; the wrapper itself adds no interactive controls, focusable elements, ARIA roles, or labels.
- Any interactivity, keyboard operability, and visible focus styling must come from the card content you pass in, since the wrapper only applies positioning and transforms.
- The wrapper exposes no roles or labels, so meaningful structure and accessible names must be supplied by the children.
- It does not currently check prefers-reduced-motion, so the cards animate for everyone; it should be guarded with a matchMedia('(prefers-reduced-motion: reduce)') check to skip or disable the scroll animation for users who prefer reduced motion.
Performance
- Animates only rotateZ and y (translateY) transforms plus zIndex, so the motion runs on the compositor and avoids layout and paint thrashing; zIndex changes are cheap and do not trigger reflow on their own.
- Uses GSAP ScrollTrigger with scrub set to 1, tying progress to scroll position via GSAP's internal requestAnimationFrame ticker rather than per-frame React renders.
- Effect cleanup calls ctx.revert() on a gsap.context, which tears down the created tweens and ScrollTrigger instances and reverts inline styles, preventing leaked listeners or stale triggers.
- No will-change is set explicitly, so the browser is not pre-hinted to promote the cards to their own layers; transforms still composite efficiently but a will-change-transform hint could reduce first-frame jank.
- Animation is not gated on reduced-motion or element visibility beyond the scroll range, so work continues for all users even when motion is unwanted.
Examples
Basic
Basic stacked cards.
Card 1
Card 2
Card 3
import { GravityScrollCards } from "@/components/gravity-scroll-cards";
<GravityScrollCards className="min-h-80">
<div className="rounded-xl border bg-linear-to-br from-violet-600 to-purple-800 p-6">
Card 1
</div>
<div className="rounded-xl border bg-linear-to-br from-fuchsia-600 to-pink-800 p-6">
Card 2
</div>
<div className="rounded-xl border bg-linear-to-br from-cyan-600 to-blue-800 p-6">
Card 3
</div>
</GravityScrollCards>;Custom stack
Custom stack offset and rotation.
Card 1
Card 2
Card 3
import { GravityScrollCards } from "@/components/gravity-scroll-cards";
<GravityScrollCards stackOffset={28} stackRotation={4}>
{/* cards with more offset and rotation */}
</GravityScrollCards>;Related reading
- The Physics Behind Natural Motion — Spring physics behind the falling cards
- Moving Things Without Moving Them — Transform-driven motion for performant scrolling
Last updated on Jun 19