Scroll Card Fan — Free GSAP Deck Spread Snippet
Scroll Card Fan · Scroll · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<section class="scf-top"><p>Scroll ↓</p></section>
<section class="scf-stage" id="scfStage">
<div class="scf-heading">
<h2>Pick a card</h2>
<p>The deck fans open as you scroll.</p>
</div>
<div class="scf-deck" id="scfDeck">
<div class="scf-card" style="--cc1:#6366f1;--cc2:#4338ca"><span>⚡</span><em>Speed</em></div>
<div class="scf-card" style="--cc1:#a855f7;--cc2:#7e22ce"><span>🛡️</span><em>Security</em></div>
<div class="scf-card" style="--cc1:#06b6d4;--cc2:#0e7490"><span>📈</span><em>Scale</em></div>
<div class="scf-card" style="--cc1:#10b981;--cc2:#047857"><span>🧩</span><em>Plugins</em></div>
<div class="scf-card" style="--cc1:#f59e0b;--cc2:#b45309"><span>🤝</span><em>Support</em></div>
</div>
</section>
<section class="scf-bottom"><p>Scroll up to sweep the fan back into a deck.</p></section>*{box-sizing:border-box;margin:0;padding:0}
body{font-family:system-ui,-apple-system,sans-serif;background:#07080d;color:#fff}
.scf-top,.scf-bottom{min-height:70vh;display:flex;justify-content:center;align-items:center;color:#8a90a8;font-size:15px;letter-spacing:.1em;text-transform:uppercase}
.scf-stage{position:relative;height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:8vh;overflow:hidden;background:radial-gradient(75% 65% at 50% 40%,#141936,#07080d)}
.scf-heading{text-align:center}
.scf-heading h2{font-size:clamp(28px,5vw,48px);font-weight:800;letter-spacing:-.02em}
.scf-heading p{color:#8a90a8;margin-top:8px;font-size:15px}
.scf-deck{position:relative;width:min(200px,44vw);aspect-ratio:5/7}
.scf-card{position:absolute;inset:0;border-radius:16px;background:linear-gradient(160deg,var(--cc1),var(--cc2));border:1px solid rgba(255,255,255,.18);box-shadow:0 24px 60px rgba(0,0,0,.5);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;transform-origin:50% 130%;will-change:transform}
.scf-card span{font-size:42px}
.scf-card em{font-style:normal;font-weight:700;font-size:16px;letter-spacing:.04em}gsap.registerPlugin(ScrollTrigger);
var cards = gsap.utils.toArray('.scf-card');
var mid = (cards.length - 1) / 2;
// Slight resting offsets so the pile reads as a physical deck.
cards.forEach(function (card, i) {
gsap.set(card, { rotation: (i - mid) * 1.5, y: -i * 2 });
});
var tl = gsap.timeline({
scrollTrigger: {
trigger: '#scfStage',
start: 'top top',
end: '+=160%',
scrub: 0.4,
pin: true
}
});
// Fan out: every card pivots around a point below the deck
// (transform-origin: 50% 130%), so rotation alone creates the arc.
cards.forEach(function (card, i) {
var spread = i - mid; // -2 .. 2
tl.to(card, {
rotation: spread * 16,
x: spread * 24,
y: Math.abs(spread) * -6,
ease: 'none'
}, 0);
});
// A late lift on the center card gives the fan a focal point.
tl.to(cards[Math.round(mid)], { y: -34, scale: 1.06, ease: 'none' }, 0.75);Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Scroll Card Fan — Fan a Deck of Cards Open With the Scrollbar

The scroll card fan takes a neat pile of cards and spreads it into a hand-of-cards arc as you scroll — the same gesture as fanning playing cards on a table, driven by the scrollbar and fully reversible. It's a compact way to introduce a set of features, plans, or categories with one satisfying motion. This snippet builds it with GSAP ScrollTrigger (from a CDN) and a single transform-origin trick.
One low pivot point creates the entire arc
Every card is absolutely stacked in the same box with transform-origin: 50% 130% — a pivot *below* the card's bottom edge. Rotating around that shared low point automatically swings each card outward and upward along a circular arc, exactly like cards pinched at one corner. Without the shifted origin you'd have to hand-calculate an x/y/rotation triple per card to fake the arc; with it, rotation alone does 90% of the work.
Spread is computed from distance-to-center
Each card's target rotation is (i − mid) × 16°, where mid is the middle index. The center card stays upright, neighbors lean ±16°, outer cards ±32° — a symmetric fan that works for any odd or even card count without editing the tween code. A small x: spread × 24 widens the fan beyond what pure rotation gives, and y: |spread| × −6 lifts outer cards slightly so their top corners align along the arc.
The deck starts as a believable pile
Before the trigger runs, gsap.set gives each card a resting offset — 1.5° of alternating rotation and 2px of vertical stagger. That messy-pile look matters: fanning from a mathematically perfect stack reads as sterile, while fanning from a slightly uneven pile reads as physical. Because these offsets are set as start values, the scrubbed tween interpolates from pile to fan and back without snapping.
All cards animate at position 0, plus one accent later
Every card's tween is placed at timeline position 0 so the whole fan opens as one gesture rather than card-by-card. Then, at 75% of the timeline, the center card lifts 34px and scales to 1.06 — a focal accent that lands only after the fan is mostly open, giving the sequence a beginning (spread) and an end (highlight) within one scroll.
Pinned, scrubbed, and compositor-only
The stage pins for +=160% with scrub: 0.4, so the fan opens exactly as far as you've scrolled and glides slightly after each wheel tick. Rotation, translation, and scale are all transforms — no layout or paint work — and will-change: transform promotes each card to its own layer up front, avoiding promotion jank mid-scroll.
z-order comes free from source order
Later cards in the DOM paint on top, so the rightmost card naturally overlaps leftward like a real right-handed fan. Reverse the DOM order (or set explicit z-indexes) to flip the overlap direction.
The fanned cards stay fully interactive
Nothing about pinning or scrubbing disables the DOM: the cards remain real elements at every scrub position, so links, hover states, and click handlers work mid-fan or fully open. ScrollTrigger's pin spacer wraps the stage without intercepting pointer events, and because the cards only carry transforms, their hit areas travel exactly with their rendered positions. A common production touch is enabling a lift-on-hover only once the fan is open — gate it by checking tl.progress() > 0.9 in a pointerenter handler, or toggle a class from an onComplete-style position callback on the timeline.
Customizing it
Change the spread angle, card count (the mid math adapts), or make each card a link. Pair the fan with a scroll sticky stack for the section after, a scroll 3d cards alternative entrance, or stacked cards for a hover-driven cousin.
Step by step
How to Use
- 1Add the GSAP CDNsInclude gsap and ScrollTrigger from the CDN panel.
- 2Paste HTML, CSS, and JSFive cards stack into a slightly messy pile.
- 3Scroll into the stageIt pins and the deck begins fanning open.
- 4Finish the scrollThe center card lifts and scales as the focal point.
- 5Scroll back upThe fan sweeps back into the pile — fully scrubbed.
- 6Add or restyle cardsThe distance-to-center math adapts to any count.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
Every card shares transform-origin: 50% 130% — a pivot below its bottom edge. Rotating around that low point swings the card outward and upward along a circle, like playing cards pinched at one corner. Without the shifted origin you would need hand-tuned x, y, and rotation per card to fake the same arc.
From its distance to the center index: rotation is (i − mid) × 16°, so the middle card stays upright and outer cards lean the most, symmetrically. A small x shift widens the fan and a negative y proportional to |spread| aligns the top corners along the arc. Change one multiplier to open or tighten the whole fan.
Yes — add or remove .scf-card elements and everything adapts, because mid is computed as (count − 1) / 2 and every tween derives from distance-to-center. For big decks, lower the 16° multiplier (for example 60° total divided by count) so outer cards don't rotate past legibility, and consider raising the pinned end distance.
gsap.set gives each card 1.5° of alternating tilt and a 2px vertical stagger before the trigger runs. A mathematically perfect stack fans open looking sterile; a slightly uneven pile reads as a physical deck. Since these are start values, the scrubbed tween interpolates smoothly from pile to fan and back without any snap.
Fully — pinning and scrubbing never disable the DOM, and transform-only animation means each card's hit area travels with its rendered position. Wrap card contents in anchors or attach click handlers as usual. To add hover lifts only after the fan opens, gate the effect on tl.progress() > 0.9 so a half-open deck doesn't respond like a finished layout.
Render cards from an array, then build the set() offsets and timeline in a mount effect (useEffect, onMounted, or ngAfterViewInit) inside gsap.context scoped to a container ref, reverting it in the cleanup so the pin unregisters on unmount. Compute mid from the array length so adding data adds cards. Card visuals translate directly to Tailwind gradient and shadow utilities.