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.

  • 34 lines of CSS
  • 8 lines of HTML
  • 24 lines of JS
  • No dependencies
  • MIT licensed

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), waits for the transition, then moves that card to the end of the order and rewrites --p for all.
  4. The other cards glide forward purely because their --p changed.

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">
    <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 34 lines

.scene {
  perspective: 800px;
}

.stack {
  position: relative;
  width: 230px;
  height: 150px;
  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: 16px;
  color: #fff;
  font: 900 1.6rem system-ui;
  background: linear-gradient(135deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 80% 46%));
  box-shadow: 0 12px 22px -12px #000;
  opacity: calc(1 - var(--p) * 0.18);
  transform: translateY(calc(var(--p) * -14px)) translateZ(calc(var(--p) * -44px));
  transition: transform 0.38s cubic-bezier(0.3, 1.2, 0.5, 1), opacity 0.38s;
}

.stack i.is-leaving {
  opacity: 0;
  transform: translateX(130%) translateZ(60px) rotateY(-35deg) rotateZ(18deg);
}

JS 24 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));
}

stack.addEventListener('click', () => {
  if (busy) return;
  busy = true;

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

  setTimeout(() => {
    order = [...order.slice(1), top];   // top card goes to the back
    top.classList.remove('is-leaving');
    layout();
    busy = false;
  }, 380);                              // same as the CSS transition
});

layout();

Sass source 35 lines

.d-cardstack {
  position: relative;
  width: 170px;
  height: 110px;
  cursor: pointer;
  transform-style: preserve-3d;
  transform: rotateX(18deg) rotateY(-14deg);
  outline-offset: 30px;

  i {
    // --p is the card's position in the deck: 0 = top. JS reassigns it, CSS does the rest.
    position: absolute;
    inset: 0;
    z-index: calc(10 - var(--p));
    display: grid;
    place-items: center;
    border-radius: 14px;
    background: linear-gradient(135deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 80% 46%));
    color: #fff;
    font-size: 22px;
    font-style: normal;
    font-weight: 900;
    box-shadow: 0 12px 22px -12px #000;
    opacity: calc(1 - var(--p) * 0.18);
    transform: translateY(calc(var(--p) * -12px)) translateZ(calc(var(--p) * -38px));
    transition:
      transform 0.38s cubic-bezier(0.3, 1.2, 0.5, 1),
      opacity 0.38s;

    &.is-leaving {
      opacity: 0;
      transform: translateX(130%) translateZ(60px) rotateY(-35deg) rotateZ(18deg);
    }
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.