Scroll Card Deal
A pile of cards that fans out symmetrically as its section crosses the viewport.
- Category
- Animations
- Added
- 2026-09-20
- Deps
- 1
- Updated
- 2026-09-20
Props
"use client";
import { useRef } from "react";
import { motion, useScroll, useTransform, type MotionValue } from "framer-motion";
type Card = { id: string; title: string };
export function ScrollCardDeal({
cards,
spread = 1,
rotation = 8,
scaleIn = true,
}: {
cards: Card[];
spread?: number;
rotation?: number;
/** Grow the cards as they deal, instead of dealing them at full size. */
scaleIn?: boolean;
}) {
const sectionRef = useRef<HTMLDivElement>(null);
// offset maps "section top hits viewport bottom" -> 0 and "section bottom
// hits viewport top" -> 1, so the deal is driven by the section's own
// passage through the viewport rather than by absolute page position.
const { scrollYProgress } = useScroll({
target: sectionRef,
offset: ["start end", "end start"],
});
return (
<div ref={sectionRef} className="relative h-[200vh]">
<div className="sticky top-1/3 flex justify-center">
<div className="relative h-40 w-56">
{cards.map((card, i) => (
<DealtCard
key={card.id}
progress={scrollYProgress}
index={i}
total={cards.length}
spread={spread}
rotation={rotation}
scaleIn={scaleIn}
card={card}
/>
))}
</div>
</div>
</div>
);
}
function DealtCard({
progress,
index,
total,
spread,
rotation,
scaleIn,
card,
}: {
progress: MotionValue<number>;
index: number;
total: number;
spread: number;
rotation: number;
scaleIn: boolean;
card: Card;
}) {
// Each card owns an OVERLAPPING slice of the scroll range, so the deal
// reads as one continuous motion rather than N discrete steps.
const start = (index / total) * 0.8;
const end = start + 0.35;
const offset = index - (total - 1) / 2;
// Hooks must run at a component's top level — that is why a dealt card is
// its own component rather than a callback inside .map().
const x = useTransform(progress, [start, end], [0, offset * 46 * spread]);
const y = useTransform(progress, [start, end], [index * 3, 0]);
const rotate = useTransform(progress, [start, end], [0, offset * rotation]);
const scale = useTransform(progress, [start, end], [scaleIn ? 0.9 : 1, 1]);
const opacity = useTransform(progress, [start, start + 0.08], [0.35, 1]);
return (
<motion.div
style={{ x, y, rotate, scale, opacity, zIndex: index }}
className="absolute inset-0 flex flex-col justify-between rounded-lg border border-white/20 bg-[#151922] p-3 shadow-lg"
>
<span className="font-mono text-[9px] tracking-[0.16em] opacity-60">{card.id}</span>
<span className="text-sm font-bold text-white">{card.title}</span>
</motion.div>
);
}
About this animation
Two choices decide whether a scroll-driven deal feels designed or mechanical. The first is where progress comes from: tying `useScroll` to the section's own passage through the viewport, with the start-end to end-start offset, means the component behaves identically wherever it lands on a page — no magic numbers to retune when a section moves. The second is range overlap. Giving each card a disjoint slice of the progress produces four animations that visibly take turns; overlapping them, so a card starts before its predecessor has finished, is what makes the sequence read as one continuous gesture. Offsets are computed relative to the centre index rather than from zero, so the fan stays symmetric at any card count instead of drifting off to one side when you add a fifth. As with any Framer scroll composition, each card has to be its own component — `useTransform` is a hook and cannot live inside a map callback once the list length can vary. Only transform and opacity animate, so the whole effect stays off the layout path; it does require real scroll distance, which makes it a desktop-section pattern rather than a mobile one.
Curator’s note
Built for viberdy 2.0. Overlapping the per-card scroll ranges is what turns four separate animations into one gesture.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Sticky Scroll Stack
patternsCards that pin and stack on top of each other as you scroll past.
Velocity Marquee Rail
animationsA marquee that accelerates with scroll speed and flips direction when you scroll back.
Scroll Depth Layers
animationsA parallax scene where every layer derives from one shared scroll source.