---
name: stacked-cards
description: Use when you want "Premium, tactile, dynamic" - Displays cards layered on top of each other.
---

# Stacked Cards

> **Category:** Card / Layout  -  **Personality:** Premium, tactile, dynamic
>
> **Best use:** Testimonials, onboarding, mobile UI
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 3/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

A single deck where several cards sit in the *same* spot, the front one full-size and the rest peeking behind it at a decreasing `translateY` + `scale`. You advance the deck by clicking an arrow, dragging the top card, pressing an arrow key, or letting it auto-play: the front card flings off, recycles to the back of the stack, and the rest tween forward one slot — so it loops forever. It's the natural home for testimonials, an onboarding flow, or any "one thing at a time" mobile surface.

This is the **click/drag-advanced** stack. Keep it distinct from a scroll-pinned card stack (driven by `ScrollTrigger`) and from a binary swipe deck (fling left/right to accept/reject). Here cards are never discarded — they cycle.

## Dependencies / CDN

None - pure CSS + vanilla JS. The demo loads two optional display/UI fonts:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Spectral:ital,wght@0,500;0,600;1,500;1,600&display=swap" rel="stylesheet">
```

## HTML

A deck of equal-sized cards plus a control row. `data-*` attributes carry whatever the live region should announce.

```html
<section class="sc-wrap" tabindex="0" role="group"
         aria-roledescription="carousel" aria-label="Customer testimonials">
  <div class="sc-deck" id="deck">
    <article class="sc-card" data-name="Maya Lindqvist" data-co="Northwind Labs">
      <span class="sc-stars" aria-label="Rated 5 out of 5">&#9733;&#9733;&#9733;&#9733;&#9733;</span>
      <p class="sc-text">We replaced four tools and a weekly status meeting with a single board&hellip;</p>
      <div class="sc-foot">
        <span class="sc-ava" aria-hidden="true">ML</span>
        <span class="sc-meta"><b>Maya Lindqvist</b><span>VP Product &middot; Northwind Labs</span></span>
      </div>
    </article>
    <!-- 4 more <article class="sc-card"> siblings (5 total) -->
  </div>

  <div class="sc-ctrl">
    <button id="prev" type="button" aria-label="Previous testimonial">&lsaquo;</button>
    <div id="dots" aria-label="Select a testimonial"></div>
    <button id="next" type="button" aria-label="Next testimonial">&rsaquo;</button>
  </div>
  <p class="sc-sr" id="live" aria-live="polite"></p>
</section>
```

## CSS

The effect is entirely in the layout + transition rules. Every card is absolutely positioned in the same box; JS writes the per-slot transform inline.

```css
/* The deck reserves room for the front card PLUS the fan that peeks below it. */
.sc-deck{ position:relative; width:100%; max-width:466px;
  height:calc(var(--cardh, 320px) + 56px); margin:0 auto; touch-action:pan-y; }

/* All cards share one slot; transform-origin at the bottom makes scaled-down
   cards peek out below the front one. JS sets transform / opacity / z-index inline. */
.sc-card{ position:absolute; left:0; right:0; top:0; height:var(--cardh, 320px);
  transform-origin:50% 100%; backface-visibility:hidden;
  will-change:transform, opacity, filter;
  transition:transform .56s cubic-bezier(.22,1,.36,1),
             opacity .45s ease, filter .56s ease;
  border-radius:22px; /* + your own surface, border, shadow, padding */ }

.sc-card.sc-front  { cursor:grab; }
.sc-card.sc-drag   { cursor:grabbing; transition:none; }                     /* follow pointer 1:1 */
.sc-card.sc-leaving{ transition:transform .5s cubic-bezier(.4,.04,.5,1), opacity .42s ease; }
.sc-card.sc-noanim { transition:none !important; }                          /* snap silently while recycling */

.sc-sr{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); }

@media (prefers-reduced-motion: reduce){ .sc-card, .sc-card.sc-leaving{ transition:none; } }
```

## JavaScript

`order` is the stack front→back. `slot(i)` maps a position to a transform; `applyPositions()` writes those transforms so the browser tweens between them. Advancing flings the front card off, then snaps it to the (hidden) back slot.

```js
const deck = document.getElementById('deck');
const cards = [...deck.querySelectorAll('.sc-card')];
const N = cards.length, MAXVIS = 4;          // slots 0..3 visible, slot 4 = hidden recycle slot
let order = cards.slice(), busy = false, dragging = false;
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

const slot = i => { const d = Math.min(i, MAXVIS);
  return { ty:d*16, s:1 - d*0.055, br:1 - d*0.075, op:d>=MAXVIS?0:1, z:100-i }; };

function applyPositions(){
  order.forEach((card, i) => {
    card.classList.toggle('sc-front', i === 0);
    card.setAttribute('aria-hidden', i === 0 ? 'false' : 'true');
    if(card.__leaving) return;               // its transform is owned by the fly animation
    const p = slot(i);
    card.style.transform = `translate3d(0,${p.ty}px,0) scale(${p.s})`;
    card.style.opacity = p.op;
    card.style.filter = `brightness(${p.br})`;
    card.style.zIndex = p.z;
    card.style.pointerEvents = i === 0 ? 'auto' : 'none';
  });
}

function next(opts = {}){                     // front flies off, recycles to the back
  if(busy) return;
  if(reduce){ order.push(order.shift()); applyPositions(); return; }
  busy = true;
  const leaving = order[0];
  leaving.__leaving = true;
  leaving.classList.remove('sc-drag', 'sc-noanim');
  leaving.classList.add('sc-leaving');
  leaving.style.zIndex = 200;
  order.push(order.shift());                  // model: front -> back
  applyPositions();                           // the rest move forward one slot
  requestAnimationFrame(() => {               // animate the fling (defaults: up-left)
    const { lx = -34, ly = -46, lr = -4 } = opts;
    leaving.style.transform = `translate3d(${lx}px,${ly}px,0) scale(1.04) rotate(${lr}deg)`;
    leaving.style.opacity = 0;
  });
  const done = () => {                         // snap to the back slot WITHOUT animating across the deck
    leaving.classList.add('sc-noanim');
    leaving.classList.remove('sc-leaving');
    leaving.__leaving = false;
    leaving.style.opacity = ''; leaving.style.zIndex = '';
    applyPositions();
    void leaving.offsetWidth;                  // reflow so the next transition is clean
    leaving.classList.remove('sc-noanim');
    busy = false;
  };
  leaving.addEventListener('transitionend', e => { if(e.propertyName === 'transform') done(); }, { once:true });
  setTimeout(done, 660);                        // safety net if transitionend is missed
}

/* Drag the front card; past a threshold it advances in the drag direction, else springs back. */
let sx, sy, dx = 0, dy = 0, front = null;
deck.addEventListener('pointerdown', e => {
  if(busy) return; front = order[0];
  if(!front.contains(e.target)) return;
  dragging = true; sx = e.clientX; sy = e.clientY; dx = dy = 0;
  front.classList.add('sc-drag');
  addEventListener('pointermove', move); addEventListener('pointerup', up);
});
function move(e){ dx = e.clientX - sx; dy = e.clientY - sy;
  front.style.transform = `translate3d(${dx}px,${dy}px,0) scale(1.02) rotate(${dx*0.04}deg)`;
  front.style.opacity = 1 - Math.min((Math.abs(dx)+Math.abs(dy))/460, 0.16);
}
function up(){ dragging = false;
  removeEventListener('pointermove', move); removeEventListener('pointerup', up);
  front.classList.remove('sc-drag');
  if(Math.hypot(dx, dy) > 90){ const a = Math.atan2(dy || -1, dx);
    next({ lx:Math.cos(a)*480, ly:Math.sin(a)*480 - 16, lr:dx*0.06 }); }
  else { front.style.opacity = ''; applyPositions(); }   // spring back to the front slot
}

document.getElementById('next').addEventListener('click', () => next());
applyPositions();
```

`prev()` mirrors `next()`: take the back card, place it just above the deck with `.sc-noanim`, then on the next frame add `.sc-leaving` and animate it into the front slot. Auto-play is just `setInterval(() => !busy && !dragging && next(), 5200)` — **gated** so it never starts when `reduce` is true, and paused on hover / focus / `document.hidden`.

## How it works

- **One stacking spot.** Every card is `position:absolute` filling the same box. The *only* thing that distinguishes them is the transform JS writes per slot, so re-ordering the `order` array + calling `applyPositions()` is the whole state machine.
- **The fan.** `transform-origin:50% 100%` (bottom-centre) means a scaled-down card keeps its bottom edge put and shrinks upward; a small positive `translateY` per depth then drops each one so a clean strip peeks out *below* the card in front. Lower `brightness()` deepens the recession.
- **CSS does the tween.** Because the cards always carry a `transition`, simply changing the inline transform animates the move. The advance is the one exception: the front card gets a bespoke `.sc-leaving` fling, and is then re-seated at the back under `.sc-noanim` so it doesn't visibly slide across the whole deck — it just reappears, small and dim, at the bottom.
- **Looping.** `order.push(order.shift())` is a rotation, so the deck is infinite in both directions.

## Customization

- **Stack depth & spacing:** edit `slot()` — bump `d*16` for a taller fan, change `1 - d*0.055` for more/less perspective, or raise `MAXVIS` to show more peeking cards.
- **Exit style:** the `lx/ly/lr` passed to `next()` set the fling vector. Default is a gentle up-left lift; the drag handler instead flings along the drag direction. Want a downward "dealt to the bottom" feel? Send a positive `ly`.
- **Feel:** the front transition ease (`cubic-bezier(.22,1,.36,1)`) controls the settle; nudge toward an overshoot bezier for a bouncier, more tactile snap.
- **Content:** swap testimonials for onboarding steps, pricing tiers, or product cards — anything that benefits from "one at a time, with the rest visibly waiting".

## Accessibility & performance

- Only `transform`, `opacity` and `filter` animate — all GPU-friendly and off the layout/paint path; `will-change` is set on the cards. The deck holds a fixed number of nodes, so cost stays flat (rated 2/5).
- Controls are real `<button>`s with labels; `←`/`→` advance the deck; an `aria-live="polite"` region announces "Testimonial 2 of 5: …" on every change, and non-front cards are `aria-hidden`.
- **Respect reduced motion:** the demo both ships a CSS `@media (prefers-reduced-motion: reduce)` (no transitions) and branches in JS — `next()`/`prev()` re-order instantly and **auto-play never starts**.
- Keep card heights equal (fixed `--cardh`) so the stack doesn't jump as content length varies.

## Gotchas

- **Reserve fan room.** The deck must be taller than a card (`height:calc(var(--cardh) + …)`) or the peeking cards get clipped. With `overflow:hidden` on a parent stage, leave headroom for the fly-off too.
- **Don't let `applyPositions()` fight the fly.** Flag the in-flight card (`__leaving`) and skip it, or its slot transform will instantly cancel the animation.
- **Re-seat under `.sc-noanim`.** If you recycle the front card to the back *with* its transition on, it visibly slides down through the whole stack. Disable the transition, snap it, force a reflow (`void el.offsetWidth`), then re-enable.
- **`touch-action:pan-y`** on the deck lets the page scroll vertically while you still own the horizontal drag — without it, touch drags either hijack the scroll or never reach your handler.
- **z-index can't tween.** It steps. Keep the leaving card on a high `z-index` for the whole fling so it never flickers behind a sibling mid-air.
