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.
Drag the top card left or right: it slides and turns, easing to a stop instead of leaving the canvas. Past a threshold it sinks away into the depth and rejoins at the back, and the cards behind move up. JS only writes numbers; CSS does every motion.
Your edited version
--p (0 = top). CSS turns that into real depth with translateZ(calc(var(--p) * -45 * var(--u))) — perspective does the shrinking, no scale() needed.pointerdown/pointermove drag is in progress, JS turns the pointer's travel from pixels into the deck's own units (the deck is 130 of them across) and writes the offsets as plain numbers (--dx, --dy, --dz). CSS multiplies each one by the model's base unit --u and does every bit of motion, so a drag means the same thing on a gallery card and on a full screen.REACH × tanh(dx / REACH) is 1:1 under the finger at first and eases to a limit however far you go. The card turns as it slides, and rotateY narrows what it draws, which is most of what pays for the slide.translateZ far into the depth while it fades — which is where it rejoins the deck anyway.transition: none so it answers the finger at once; release it and the transition comes back, so it either springs home or carries on away..is-back (opacity 0, no transition) to jump it behind the deck invisibly, then removes that class a frame later so it fades back in at the bottom — the illusion of an endless deck.setPointerCapture so the drag keeps receiving events even if the pointer leaves the card, and arrow keys call the exact same fling() function as a completed drag.Pointer Events + setPointerCapturestack position --p → translateZtransition: none while draggingclass swap for the fly-off<div class="scene">
<div class="swipe" tabindex="0">
<i style="--hue:262"><b>LIKE</b><b>NOPE</b><strong>Cube</strong><small>six faces</small></i>
<i style="--hue:320"><b>LIKE</b><b>NOPE</b><strong>Ring</strong><small>rotate, then translate</small></i>
<i style="--hue:175"><b>LIKE</b><b>NOPE</b><strong>Flap</strong><small>hinged on an edge</small></i>
<i style="--hue:28"><b>LIKE</b><b>NOPE</b><strong>Lens</strong><small>perspective: 800px</small></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.36vmin;
perspective: calc(800 * var(--u));
}
.swipe {
position: relative;
width: calc(130 * var(--u));
height: calc(150 * var(--u));
outline: none;
touch-action: pan-y; /* horizontal drags are ours, vertical ones still scroll the page */
transform-style: preserve-3d;
transform: translateY(calc(-14 * var(--u))); /* the stack trails off downward: nudge it up */
}
.swipe:focus-visible i.is-top {
outline: calc(2 * var(--u)) solid #2ee6d6;
outline-offset: calc(3 * var(--u));
}
/* --p is the position in the stack (0 = top), written by JS. Depth is REAL: cards further
back sit further away on Z, and the perspective makes them smaller — no scale() needed.
--dx / --dy / --dz / --rot / --tilt are plain numbers the pointer writes, and every one of
them is multiplied by the model's own unit here, so a drag means the same thing on a card
and on a full screen. */
.swipe i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
gap: calc(2 * var(--u));
padding: calc(12 * var(--u));
border: calc(1 * var(--u)) solid hsl(var(--hue) 90% 78% / 0.7);
border-radius: calc(14 * var(--u));
background:
radial-gradient(circle at 75% 22%, rgb(255 255 255 / 0.5), transparent 32%),
linear-gradient(160deg, hsl(var(--hue) 85% 64%), hsl(calc(var(--hue) + 40) 75% 34%));
box-shadow: 0 calc(14 * var(--u)) calc(20 * var(--u)) calc(-14 * var(--u)) #000;
color: #fff;
font-style: normal;
opacity: calc(1 - var(--p) * 0.2);
user-select: none;
touch-action: pan-y;
transform: translate3d(
calc(var(--dx, 0) * var(--u)),
calc((var(--p) * 14 + var(--dy, 0)) * var(--u)),
calc((var(--p) * -45 + var(--dz, 0)) * var(--u))
)
rotateY(var(--tilt, 0deg)) rotateZ(var(--rot, 0deg));
transition:
transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1),
opacity 0.3s;
}
.swipe i.is-top {
cursor: grab;
}
/* while the finger is down the card must follow 1:1, so no transition */
.swipe i.is-dragging {
cursor: grabbing;
transition: none;
}
/* on its way out: JS has set the far pose, CSS does the flight */
.swipe i.is-leaving {
opacity: 0;
transition:
transform 0.35s ease-in,
opacity 0.35s ease-in;
}
/* re-entering at the back: jump there invisibly, then fade in */
.swipe i.is-back {
opacity: 0;
transition: none;
}
.swipe i strong {
font-size: calc(17 * var(--u));
line-height: 1;
}
.swipe i small {
font-size: calc(11 * var(--u));
opacity: 0.85;
}
/* LIKE / NOPE stamps, faded in by how far the card has been dragged */
.swipe i b {
position: absolute;
top: calc(12 * var(--u));
padding: calc(2 * var(--u)) calc(6 * var(--u));
border: calc(2 * var(--u)) solid currentcolor;
border-radius: calc(6 * var(--u));
font-size: calc(11 * var(--u));
letter-spacing: 0.08em;
}
.swipe i b:nth-of-type(1) {
left: calc(10 * var(--u));
color: #7dffb0;
opacity: var(--like, 0);
transform: rotate(-14deg);
}
.swipe i b:nth-of-type(2) {
right: calc(10 * var(--u));
color: #ffd0d8;
opacity: var(--nope, 0);
transform: rotate(14deg);
}
const root = document.querySelector('.swipe');
let order = [...root.querySelectorAll('i')];
let drag = null;
let busy = false;
let timer = 0;
const WIDE = 130; // the deck's width in the model's own units, for pixels → units
const THRESHOLD = 26; // pointer travel, in units, that counts as sending the card away
// The canvas IS the model, so there is no off-screen edge to fling a card over, and a card that
// slid far enough to look flung would drag the whole picture off centre. So the slide gives:
// however far the finger goes, the card eases to REACH and no further, and it turns as it goes —
// rotateY narrows what it draws, which is most of what pays for the slide. A sent card leaves by
// going BACK instead, to where it rejoins the deck anyway.
const REACH = 18; // the furthest across a card is ever drawn
const RISE = 8; // and the furthest up or down
const GONE = 900; // how far back a sent card goes while it fades
/** Eases to a limit: 1:1 under the finger at first, never past the limit however far it goes. */
function ease(v, limit) {
return limit * Math.tanh(v / limit);
}
function layout(cards) {
cards.forEach((el, p) => {
el.style.setProperty('--p', String(p));
el.classList.toggle('is-top', p === 0);
});
}
function pose(card, dx, dy, back) {
const across = ease(dx, REACH);
card.style.setProperty('--dx', across.toFixed(1));
card.style.setProperty('--dy', ease(dy, RISE).toFixed(1));
card.style.setProperty('--dz', String(-(back || 0)));
card.style.setProperty('--rot', (across * 0.15).toFixed(2) + 'deg');
card.style.setProperty('--tilt', (across * 1.3).toFixed(2) + 'deg');
card.style.setProperty('--like', Math.min(1, Math.max(0, dx / THRESHOLD)).toFixed(2));
card.style.setProperty('--nope', Math.min(1, Math.max(0, -dx / THRESHOLD)).toFixed(2));
}
function unpose(card) {
['--dx', '--dy', '--dz', '--rot', '--tilt', '--like', '--nope'].forEach((p) => card.style.removeProperty(p));
}
function fling(dir, dy) {
if (busy) return;
busy = true;
const card = order[0];
const rest = order.slice(1);
card.classList.remove('is-top');
card.classList.add('is-leaving');
pose(card, dir * THRESHOLD * 4, dy || 0, GONE); // out to its limit, and away into the depth
layout(rest); // the others move up right away
timer = window.setTimeout(() => {
// jump to the back invisibly (no transition), then let it fade in there
card.classList.add('is-back');
card.classList.remove('is-leaving');
unpose(card);
order = [...rest, card];
layout(order);
void card.offsetWidth;
card.classList.remove('is-back');
busy = false;
}, 360);
}
function down(e) {
if (busy || drag || e.button > 0) return;
const card = e.target.closest('i');
if (card !== order[0]) return;
e.preventDefault();
root.focus({ preventScroll: true });
root.setPointerCapture(e.pointerId);
// screen pixels → the model's own units: the deck is WIDE units across, so one unit is that
// fraction of what it measures on screen, whatever size the canvas is
const unit = root.getBoundingClientRect().width / WIDE || 1;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, unit, dx: 0 };
order[0].classList.add('is-dragging');
}
function move(e) {
if (!drag || e.pointerId !== drag.id) return;
drag.dx = (e.clientX - drag.x) / drag.unit;
pose(order[0], drag.dx, ((e.clientY - drag.y) / drag.unit) * 0.4);
}
function up(e) {
if (!drag || e.pointerId !== drag.id) return;
const { dx, unit, y } = drag;
drag = null;
const card = order[0];
card.classList.remove('is-dragging');
if (e.type === 'pointerup' && Math.abs(dx) > THRESHOLD) {
fling(Math.sign(dx), ((e.clientY - y) / unit) * 0.4);
} else {
unpose(card); // the transition is back on, so it springs home
}
}
function key(e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
e.preventDefault();
fling(e.key === 'ArrowLeft' ? -1 : 1);
}
layout(order);
root.addEventListener('pointerdown', down);
root.addEventListener('pointermove', move);
root.addEventListener('pointerup', up);
root.addEventListener('pointercancel', up);
root.addEventListener('keydown', key);