Gravity Scroll Cards
Cards that stack and unstack with scroll. Gravity-style depth effect.
Last updated Mar 5, 2026
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(
"-translate-x-1/2 absolute top-4 left-1/2",
cardClassName,
)}
style={{
width:
typeof cardWidth === "number"
? `${cardWidth}px`
: cardWidth,
zIndex: count - i,
}}
>
{child}
</div>
) : null,
)}
</div>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/gravity-scroll-cards.json"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(
"-translate-x-1/2 absolute top-4 left-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. |
Examples
Basic
Basic stacked cards.
Card 1
Card 2
Card 3
import { GravityScrollCards } from "@/components/gravity-scroll-cards";
<GravityScrollCards className="min-h-[320px]">
<div className="rounded-xl border bg-gradient-to-br from-violet-600 to-purple-800 p-6">
Card 1
</div>
<div className="rounded-xl border bg-gradient-to-br from-fuchsia-600 to-pink-800 p-6">
Card 2
</div>
<div className="rounded-xl border bg-gradient-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>;