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.
JS gives each cover its offset from the active one; CSS calc() does the placement and easing.
Your edited version
--o (offset from active), --abs and --sign.calc() — JS never writes a transform.transition animates every move.z-index by distance keeps nearer covers on top.--u, so they are the same share of a gallery card, the editor and a recording canvas. The arrows below sit in the standard control zone, in plain vmin, because that is the same object in every model that has one.calc() with per-item custom propertiestransitionkeyboard arrows<div class="coverflow">
<div class="view" tabindex="0" aria-label="Cover flow. Use the left and right arrow keys">
<div class="track">
<i style="--hue:250">1</i><i style="--hue:272">2</i>
<i style="--hue:294">3</i><i style="--hue:316">4</i>
<i style="--hue:338">5</i><i style="--hue:360">6</i>
<i style="--hue:22">7</i>
</div>
</div>
<div class="controls">
<output class="caption" aria-live="polite">4 / 7</output>
<div class="row">
<button type="button" data-dir="-1" aria-label="Previous">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button type="button" data-dir="1" aria-label="Next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6"/></svg>
</button>
</div>
</div>
</div>
.coverflow {
/* one base unit: every length in the covers is a multiple of it, so the flow is the same share
of a card, the editor, a full screen and a recording canvas. The control zone under it is in
plain vmin, because it is the same object in every model. */
--u: 0.25vmin;
display: grid;
justify-items: center;
gap: 4vmin; /* the band's gap between the model and the control zone */
font-family: Inter, system-ui, sans-serif;
}
/* the model box: the same height in every model that has controls, so the zone below it lands
in the same place whatever the model is */
.view {
display: grid;
place-items: center;
height: 50vmin;
perspective: calc(700 * var(--u));
outline-offset: calc(6 * var(--u));
}
.track {
position: relative;
width: calc(130 * var(--u));
height: calc(165 * var(--u));
transform-style: preserve-3d;
pointer-events: none; /* same plane as the active cover: let the covers take the pointer */
}
.track i {
/* --o, --abs and --sign are set per cover from JS */
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: calc(12 * var(--u));
font: 900 calc(35 * var(--u)) Inter, system-ui;
color: #fff;
cursor: pointer;
pointer-events: auto;
/* deep enough for the white numbers, 3:1 or more at their size, on either stage */
background: linear-gradient(160deg, hsl(var(--hue) 80% 56%), hsl(var(--hue) 70% 34%));
box-shadow: 0 calc(16 * var(--u)) calc(24 * var(--u)) calc(-14 * var(--u)) #000;
/* the next card each side stays whole and dims with brightness, not opacity: see-through, on the
light stage its number went pale (2.7:1); the ones further out fade away as before */
filter: brightness(calc(1 - var(--abs) * 0.12));
opacity: clamp(0, 2.2 - var(--abs) * 0.8, 1);
transform:
translateX(calc(var(--o) * 44 * var(--u) + var(--sign) * 34 * var(--u)))
translateZ(calc(var(--abs) * -60 * var(--u)))
rotateY(calc(var(--sign) * -58deg));
transition: transform 0.55s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.55s, filter 0.55s;
}
/* Anything more than two out is off the end of the flow: gone, and out of hit-testing with it,
so a click never lands on a cover you cannot see. This is what holds the width still. */
.track i.is-far {
opacity: 0;
pointer-events: none;
}
/* the control zone: the same object, at the same size, in every model that has one — so it is
written in plain vmin and not in the covers' own unit. The caption's line box never changes
height, so counting up cannot move the flow above it. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
opacity: 0.7;
}
.controls .row {
display: flex;
gap: 2vmin;
}
.controls button,
.controls label {
height: 8vmin;
min-width: 8vmin;
padding: 0 3vmin;
box-sizing: border-box; /* a label is content-box, so min-width would add to its padding */
border: 0;
border-radius: 999px;
/* see-through, so the pill sits on the stage, and its word is the stage's own ink at full
strength: no colour of the model's, which would vanish on one of the two stages */
background: rgb(140 150 220 / 0.2);
color: inherit;
font: 600 4vmin Inter, system-ui, sans-serif;
cursor: pointer;
transition: background-color 0.35s;
}
.controls button:hover,
.controls label:hover {
background-color: rgb(140 150 220 / 0.34);
}
.controls :focus-visible {
outline: 0.6vmin solid #6a45f5;
outline-offset: 0.6vmin;
}
/* the selected pill: the brand gradient, deep enough that white text on it passes */
.controls [aria-pressed='true'] {
background: linear-gradient(135deg, #6a45f5, #d1206f);
color: #fff;
}
.controls button {
display: grid;
place-items: center;
}
/* SVG arrows, not text like ‹ ›: a glyph sits on the font's baseline, so it never centres */
.controls svg {
width: 4vmin;
height: 4vmin;
}
const root = document.querySelector('.coverflow');
const items = [...root.querySelectorAll('.track i')];
const caption = root.querySelector('.caption');
let active = Math.floor(items.length / 2);
function layout() {
items.forEach((el, i) => {
const o = i - active;
el.style.setProperty('--o', o);
el.style.setProperty('--abs', Math.abs(o));
el.style.setProperty('--sign', Math.sign(o));
el.style.zIndex = items.length - Math.abs(o);
// past the third cover out there is nothing left to see: drop it, and its hit area with it
el.classList.toggle('is-far', Math.abs(o) > 2);
});
caption.textContent = (active + 1) + ' / ' + items.length;
}
function go(dir) {
active = Math.max(0, Math.min(items.length - 1, active + dir));
layout();
}
root.addEventListener('click', (e) => {
const btn = e.target.closest('[data-dir]');
if (btn) return go(Number(btn.dataset.dir));
const i = items.indexOf(e.target);
if (i >= 0) go(i - active);
});
root.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') go(-1);
if (e.key === 'ArrowRight') go(1);
});
layout();