Hover card fan
Pure CSSThree overlapping cards: the one you point at straightens and comes toward you while the others dim and lean away. Static strips catch the pointer, so nothing flickers.
Click the deck: the top card flies off and returns at the back. JS only reassigns positions.
Your edited version
--p (0 = top).z-index via calc()..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.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..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.--p changed.--u, so the deck is the same share of a gallery card, the editor and a recording canvas.position index in --pcalc() for offset, depth and z-indexclass toggle for the exit<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>
.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;
}
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();