A card stack

stack, from @carouseldeck/react/stack, gives the cards their order and nothing else. The focal card is on top, the cards after it are behind it in order, and a card leaving drops behind every card halfway through, so it can come back in at the back. It sets no transform, colour or opacity. The look depends on your card's size and design, so it is your CSS, on --deck-slide-progress.

This recipe draws a stack where the cards behind the top one are smaller and dimmer by depth and peek out above it. Next, in the card, throws the top card out to the side, clear of the stack, and brings it back in at the back.

import * as Deck from '@carouseldeck/react';
import { stack } from '@carouseldeck/react/stack';

const cards = [
  { title: 'Draft', text: 'Write it down before it slips away.' },
  { title: 'Review', text: 'Read it again tomorrow, with fresh eyes.' },
  { title: 'Ship', text: 'Send it out, then start the next one.' }
];

export function Cards() {
  return (
    <Deck.Root aria-label="Steps" className="cards" loop>
      <Deck.Viewport effect={stack}>
        {cards.map((card) => (
          <Deck.Slide key={card.title}>
            <div className="card">
              <h3>{card.title}</h3>
              <p>{card.text}</p>
              <Deck.Next>Next</Deck.Next>
            </div>
          </Deck.Slide>
        ))}
      </Deck.Viewport>
    </Deck.Root>
  );
}

Each slide holds one .card, which the CSS below draws. Every card but the top one is inert, so only the top card's Next can be pressed.

From progress

A card's progress is 0 on top, 1 for the card just behind it, 2 for the next, and runs from 0 to -1 as the card leaves. The CSS derives three custom properties from it:

  • --exit, how far the card has been thrown, from 0 on top to 1 when it has gone.
  • --return, how far it has come back in, from 0 at halfway to 1 when it has landed.
  • --depth, its place in the stack: its progress behind the top card, and on the way back in, a depth that grows with --return to the back of the stack, one less than --deck-slide-count, the number of slides.

Two transforms are built on them, so you can change either alone: --thrown, the throw, on --exit, and --behind, the look of a card behind the top one, on --depth.

The throw

The card moves out toward the inline start by sin(exit × 180°) of --throw: clear of the stack by 16px halfway, tilted 6deg, and back in place as it lands. Halfway, the effect drops the card behind the stack, so on the way back in it slides under the other cards. In a right-to-left deck, the inline start is the right, so --start flips the throw.

Depth

Behind the top card, each card is 14px higher and 6% smaller per step of depth, and dimmer, with filter: brightness(): opacity would show the cards through one another. The thrown card's depth grows on its way back in, so it shrinks and dims into place at the back.

Loop

The stack needs loop for the thrown card to become the last. With loop, the card's copy after the slides sits at the back as the card goes: its progress runs from the slide count to one less, so it arrives where the thrown card lands. --deck-slide-count does not count copies, so it is the same on every slide and copy. Without loop, a thrown card leaves the stack, and the stack runs down to the last card.

What is shown

The cards do not fade. A card outside the stack is hidden outright: one thrown all the way, at progress -1, where its copy now sits, and the loop's copies waiting their turn, at the slide count or beyond. The engine writes progress in whole thousandths of a slide, and the two windows meet halfway between two of them: in a deck of three, the thrown card shows down to -0.998, and its copy from 2.001. So every frame shows exactly one of the two. A scroll can rest a thousandth short of a snap point, as Firefox's does: with both hidden there, the stack would show a hole at the back, and with both shown, its shadow would darken for a moment.

Reduced motion

Under reduced motion, --exit is a step: the top card stays in place and cuts to the back halfway, with no throw.

Room for the throw

At its furthest, the thrown card is a card's width plus 16px beside the stack. Leave the viewport that much room. The stack is centred here, so the room is on each side: about three cards' width in all, 872px for these 280px cards. Overflow toward the inline start never scrolls, so a narrower viewport clips the throw.

/* Each slide fills the viewport, as stack lays them out: centre its card. */
.cards [data-carouseldeck-viewport] {
  height: 320px;
}
.cards [data-carouseldeck-slide] {
  display: grid;
  place-items: center;
}
.cards .card {
  /* From progress: how far the card has been thrown (0 to 1); how far it
     has come back in, past halfway (0 to 1); and its depth in the stack,
     which on the way back in grows to the back, one less than the count. */
  --p: var(--deck-slide-progress);
  --exit: clamp(0, -1 * var(--p), 1);
  --return: clamp(0, var(--exit) * 2 - 1, 1);
  --depth: calc(
    max(var(--p), 0) + var(--return) * (var(--deck-slide-count) - 1)
  );
  /* Thrown toward the inline start: at its furthest, halfway, clear of the
     stack by 16px and tilted 6deg; back in place as it lands. */
  --start: -1;
  --throw: calc(100% + 16px);
  --thrown: translateX(
      calc(var(--start) * var(--throw) * sin(var(--exit) * 0.5turn))
    )
    rotate(calc(var(--start) * 6deg * sin(var(--exit) * 0.5turn)));
  /* Behind: higher and smaller by depth, so each card peeks out above the
     one in front of it. */
  --behind: translateY(calc(-14px * var(--depth)))
    scale(calc(1 - 0.06 * var(--depth)));

  box-sizing: border-box;
  width: 280px;
  padding: 24px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
  transform-origin: 50% 0;
  transform: var(--thrown) var(--behind);
  /* Dimmer by depth. Not opacity, which would show the cards through one
     another. */
  filter: brightness(calc(1 - 0.06 * var(--depth)));
  /* No fade. A card outside the stack is hidden outright: one thrown all
     the way (where its copy now sits, the same card in the same place) and
     the loop's copies waiting their turn. The engine writes progress in
     whole thousandths of a slide, and the two windows meet halfway between
     two of them: the thrown card shows down to -0.998, its copy from 2.001
     in a deck of three. So each frame shows one of them, never neither (a
     hole, where Firefox's scroll rests a thousandth short) and never both
     (a doubled shadow). */
  opacity: calc(
    clamp(0, (var(--p) + 0.9985) * 2000, 1) *
      clamp(0, (var(--deck-slide-count) - 0.9985 - var(--p)) * 2000, 1)
  );
}
.cards:dir(rtl) .card {
  --start: 1;
}
/* No throw: the top card cuts to the back halfway. */
@media (prefers-reduced-motion: reduce) {
  .cards .card {
    --exit: clamp(0, (-1 * var(--p) - 0.5) * 1000, 1);
  }
}

Restyle it

Change --behind for another look. A fanned pile, after the cards above, turns and shifts each card behind the top one by its depth:

/* A fanned pile: behind the top card, each card shifts and turns by its
   depth, alternately up-right and down-left, and grows a little. The cards
   behind stick out on both sides, so the throw clears them by 24px. */
.cards .card {
  --throw: calc(100% + 24px);
  --behind: translate(
      calc(sin(var(--depth) * 120deg) * 14px),
      calc(sin(var(--depth) * 120deg) * -10px)
    )
    rotate(calc(sin(var(--depth) * 120deg) * 9deg))
    scale(calc(1 + min(var(--depth), 2) * 0.04));
  transform-origin: 50% 50%;
}

Change --thrown to throw the card another way. Thrown up, it needs room above the stack instead of beside it, which suits a narrow screen. It goes its own height, the 16px gap and the cards peeking above it, so here the cards sit at the bottom of a taller viewport:

.cards [data-carouseldeck-viewport] {
  height: 420px;
}
.cards [data-carouseldeck-slide] {
  place-items: end center;
}
.cards .card {
  margin-block-end: 24px;
  --thrown: translateY(
    calc(
      -1 * (100% + 16px + 14px * (var(--deck-slide-count) - 1)) *
        sin(var(--exit) * 0.5turn)
    )
  );
}

The stories are Recipes / Card Stack and Recipes / Card Stack Fanned. An end-to-end test throws the top card through a full loop and checks that every frame shows each card once.