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. Past a threshold it flies off and rejoins at the back, and the cards behind move up through real depth. 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) * -45px)) — perspective does the shrinking, no scale() needed.--dx, --rot...) while a pointerdown/pointermove drag is in progress; the CSS transform and transition do every bit of motion.transition: none so it follows the finger 1:1; release it and the transition comes back, so it either springs home or flies off..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 {
perspective: 800px;
}
.swipe {
position: relative;
width: 130px;
height: 150px;
outline: none;
touch-action: pan-y; /* horizontal drags are ours, vertical ones still scroll the page */
transform-style: preserve-3d;
transform: translateY(-14px); /* the stack trails off downward: nudge it up to look centred */
}
.swipe:focus-visible i.is-top {
outline: 2px solid #2ee6d6;
outline-offset: 3px;
}
/* --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 / --rot / --tilt follow the pointer while dragging. */
.swipe i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
gap: 2px;
padding: 12px;
border: 1px solid hsl(var(--hue) 90% 78% / 0.7);
border-radius: 14px;
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 14px 20px -14px #000;
color: #fff;
font-style: normal;
opacity: calc(1 - var(--p) * 0.2);
user-select: none;
touch-action: pan-y;
transform: translate3d(var(--dx, 0px), calc(var(--p) * 14px + var(--dy, 0px)), calc(var(--p) * -45px))
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;
}
/* flying off: JS has set --dx far outside the deck, 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: 17px;
line-height: 1;
}
.swipe i small {
font-size: 11px;
opacity: 0.85;
}
/* LIKE / NOPE stamps, faded in by how far the card has been dragged */
.swipe i b {
position: absolute;
top: 12px;
padding: 2px 6px;
border: 2px solid currentcolor;
border-radius: 6px;
font-size: 11px;
letter-spacing: 0.08em;
}
.swipe i b:nth-of-type(1) {
left: 10px;
color: #7dffb0;
opacity: var(--like, 0);
transform: rotate(-14deg);
}
.swipe i b:nth-of-type(2) {
right: 10px;
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 THRESHOLD = 60;
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) {
card.style.setProperty('--dx', dx.toFixed(1) + 'px');
card.style.setProperty('--dy', dy.toFixed(1) + 'px');
card.style.setProperty('--rot', (dx * 0.08).toFixed(2) + 'deg');
card.style.setProperty('--tilt', (dx * 0.12).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', '--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 * 260, dy || 0);
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 → CSS pixels (in case the page scales the deck)
const scale = root.getBoundingClientRect().width / root.offsetWidth || 1;
drag = { id: e.pointerId, x: e.clientX, y: e.clientY, scale, 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.scale;
pose(order[0], drag.dx, ((e.clientY - drag.y) / drag.scale) * 0.4);
}
function up(e) {
if (!drag || e.pointerId !== drag.id) return;
const { dx, scale, 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) / scale) * 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);
.d-swipe {
position: relative;
width: 130px;
height: 150px;
outline: none;
touch-action: pan-y; // horizontal drags are ours, vertical ones still scroll the page
transform-style: preserve-3d;
// the stack trails off downward, so nudge the whole thing up to look centred
transform: translateY(-14px);
&:focus-visible i.is-top {
outline: 2px solid var(--accent-2);
outline-offset: 3px;
}
// --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 / --rot / --tilt follow the pointer while dragging.
i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
gap: 2px;
padding: 12px;
border: 1px solid hsl(var(--hue) 90% 78% / 0.7);
border-radius: 14px;
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 14px 20px -14px #000;
color: #fff;
font-style: normal;
opacity: calc(1 - var(--p) * 0.2);
user-select: none;
touch-action: pan-y;
transform: translate3d(var(--dx, 0px), calc(var(--p) * 14px + var(--dy, 0px)), calc(var(--p) * -45px))
rotateY(var(--tilt, 0deg)) rotateZ(var(--rot, 0deg));
transition:
transform 0.4s cubic-bezier(0.3, 1.3, 0.5, 1),
opacity 0.3s;
&.is-top {
cursor: grab;
}
// while the finger is down the card must follow 1:1, so no transition
&.is-dragging {
cursor: grabbing;
transition: none;
}
// flying off: JS has set --dx far outside, CSS does the flight
&.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
&.is-back {
opacity: 0;
transition: none;
}
strong {
font-size: 17px;
line-height: 1;
}
small {
font-size: 11px;
opacity: 0.85;
}
// LIKE / NOPE stamps, faded in by how far the card has been dragged
b {
position: absolute;
top: 12px;
padding: 2px 6px;
border: 2px solid currentcolor;
border-radius: 6px;
font-size: 11px;
letter-spacing: 0.08em;
}
b:nth-of-type(1) {
left: 10px;
color: #7dffb0;
opacity: var(--like, 0);
transform: rotate(-14deg);
}
b:nth-of-type(2) {
right: 10px;
color: #ffd0d8;
opacity: var(--nope, 0);
transform: rotate(14deg);
}
}
}