CSS + JS Cards & galleries #controls

Card stack in CSS + JavaScript

Click the deck: the top card flies off and returns at the back. JS only reassigns positions.

  • 61 lines of CSS
  • 8 lines of HTML
  • 37 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Each card stores its position in the deck in --p (0 = top).
  2. CSS derives everything from it: offset, depth, opacity and even z-index via calc().
  3. On click, JS adds .is-leaving to the top card (it flies off, shrinking to nothing), waits for its transitionend, then moves that card to the end of the order and rewrites --p for all. Waiting for the event rather than a timer keeps the order in step with what is on screen, even when the transition is slowed or paused.
  4. It shrinks away with scale(0) rather than fading out. A card whose opacity is being animated is drawn after the cards around it, whatever their depth, so a thrown card that fades looks as if it passes behind, and through, the card that was under it.
  5. The card that left must not glide back to the bottom of the deck either: from where it was thrown, that path runs straight through the cards still in the deck. So it is parked first (.is-parked: no transition, still at scale(0)), jumps to the back unseen, and only then, with the class taken off after a reflow, grows back in there.
  6. The other cards glide forward purely because their --p changed.
  7. Every length is a multiple of one base unit, --u, so the deck is the same share of a gallery card, the editor and a recording canvas.
  8. The band has to hold the throw, not just the deck: the card in flight is the widest this model ever gets. It shrinks as it goes, so it can travel 60% of a card to the right and still end up inside, and the scene is padded on the right by enough to split the difference, which puts the deck and its flight path in the middle rather than the shut deck on its own.

Key techniques

  • position index in --p
  • calc() for offset, depth and z-index
  • class toggle for the exit

Copy-paste code

HTML 8 lines

<div class="scene">
  <div class="stack" role="button" tabindex="0" aria-label="Next card">
    <i style="--hue:255">One</i>
    <i style="--hue:290">Two</i>
    <i style="--hue:325">Three</i>
    <i style="--hue:360">Four</i>
  </div>
</div>

CSS 61 lines

.scene {
  /* one base unit: every length below is a multiple of it, so the deck is the same share of a
     card, the editor, a full screen and a recording canvas */
  --u: 0.34vmin;
  display: grid;
  place-items: center;
  /* the top card is thrown to the right: a little room on that side splits the difference
     between the shut deck and the deck with a card in flight, so neither is off centre */
  padding-right: calc(30 * var(--u));
  perspective: calc(800 * var(--u));
}

.stack {
  position: relative;
  width: calc(210 * var(--u));
  height: calc(140 * var(--u));
  cursor: pointer;
  transform-style: preserve-3d;
  transform: rotateX(18deg) rotateY(-14deg);
}

.stack i {
  /* --p = position in the deck, set from JS */
  position: absolute;
  inset: 0;
  z-index: calc(10 - var(--p));
  display: grid;
  place-items: center;
  border-radius: calc(16 * var(--u));
  color: #fff;
  font: 900 calc(26 * var(--u)) Inter, system-ui;
  background: linear-gradient(135deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 80% 46%));
  box-shadow: 0 calc(12 * var(--u)) calc(22 * var(--u)) calc(-12 * var(--u)) #000;
  opacity: calc(1 - var(--p) * 0.18);
  /* the same list of functions in every state, so each one is interpolated on its own */
  transform:
    translateX(0) translateY(calc(var(--p) * -14 * var(--u))) translateZ(calc(var(--p) * -44 * var(--u)))
    rotateY(0deg) rotateZ(0deg) scale(1);
  transition: transform 0.38s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.38s;
}

/* 60% of a card to the right, tilted and turned away, shrinking to nothing as it goes. The band
   is centred on everything drawn, so a card that sailed off the right at full size would drag
   the whole picture left with it; shrinking keeps the flight small as well as the deck. */
.stack i.is-leaving {
  /* it shrinks away instead of fading: a card whose opacity is animating is drawn after the
     cards around it whatever their depth, so a fading card seems to pass behind the next one */
  transform:
    translateX(60%) translateY(0) translateZ(calc(60 * var(--u)))
    rotateY(-35deg) rotateZ(12deg) scale(0);
  transition-timing-function: ease-in-out;
}

/* back from the throw: it jumps to the back of the deck while it cannot be seen, instead of
   gliding back there through the cards in front of it, and grows in from there */
.stack i.is-parked {
  transform:
    translateX(0) translateY(calc(var(--p) * -14 * var(--u))) translateZ(calc(var(--p) * -44 * var(--u)))
    rotateY(0deg) rotateZ(0deg) scale(0);
  transition: none;
}

JS 37 lines

const stack = document.querySelector('.stack');
let order = [...stack.querySelectorAll('i')];
let busy = false;

function layout() {
  order.forEach((card, p) => card.style.setProperty('--p', p));
}

function deal() {
  if (busy) return;
  busy = true;

  const top = order[0];
  top.classList.add('is-leaving');

  top.addEventListener('transitionend', function landed(e) {
    if (e.propertyName !== 'transform') return;
    top.removeEventListener('transitionend', landed);
    order = [...order.slice(1), top];   // top card goes to the back
    top.classList.add('is-parked');     // unseen, and with no transition, so it jumps there
    top.classList.remove('is-leaving');
    layout();
    void top.offsetWidth;               // let it land at the back before it fades in
    top.classList.remove('is-parked');
    busy = false;
  });
}

stack.addEventListener('click', deal);
// it is a button to the keyboard too: Enter or Space deals
stack.addEventListener('keydown', (e) => {
  if (e.key !== 'Enter' && e.key !== ' ') return;
  e.preventDefault();
  deal();
});

layout();
Standalone snippet · plain CSS, no build step, no dependencies.