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.
Four slides on the sides of a box. JS counts steps; CSS turns the box by 90° per step.
Your edited version
rotateY(n × 90deg) translateZ(width / 2).translateZ(-width / 2), so whichever face is in front sits at z = 0 and renders at its true size.rotateY(calc(var(--step) * -90deg)).--u, tied to the canvas: a slide is 280 × 170 of them, so the box is the same share of a gallery card, the editor and a recording canvas. The arrows are in plain vmin, the same size in every model that has controls.rotateY(calc(var(--step) * -90deg))unbounded counter = endless rotationtranslateZ(-w/2) keeps the front at z = 0<div class="slider">
<div class="scene">
<div class="box">
<i style="--hue:250">Design</i>
<i style="--hue:290">Build</i>
<i style="--hue:330">Ship</i>
<i style="--hue:10">Repeat</i>
</div>
</div>
<div class="controls">
<nav 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>
</nav>
</div>
</div>
.slider {
/* one base unit: every length in the box below is a multiple of it, so the slideshow is the
same share of a gallery card, the editor, a full screen and a recording canvas. The control
row 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;
}
.scene {
perspective: calc(800 * var(--u));
}
.box {
--w: calc(280 * var(--u));
position: relative;
width: var(--w);
height: calc(170 * var(--u));
transform-style: preserve-3d;
transform: translateZ(calc(var(--w) / -2)) rotateY(calc(var(--step, 0) * -90deg));
transition: transform 0.7s cubic-bezier(0.3, 1.25, 0.5, 1);
}
.box i {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: calc(8 * var(--u));
color: #fff;
font: 900 calc(32 * var(--u)) Inter, system-ui;
background: linear-gradient(135deg, hsl(var(--hue) 85% 62%), hsl(var(--hue) 75% 38%));
backface-visibility: hidden;
transform-style: preserve-3d;
}
/* plugs the see-through notches where the rounded corners of two slides meet */
.box i::before {
content: '';
position: absolute;
inset: calc(6 * var(--u));
background: hsl(var(--hue) 75% 34%);
transform: translateZ(calc(-6 * var(--u)));
}
.box i:nth-child(1) { transform: rotateY(0deg) translateZ(calc(var(--w) / 2)); }
.box i:nth-child(2) { transform: rotateY(90deg) translateZ(calc(var(--w) / 2)); }
.box i:nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--w) / 2)); }
.box i:nth-child(4) { transform: rotateY(270deg) translateZ(calc(var(--w) / 2)); }
/* 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 box's own unit */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.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: 4.5vmin;
height: 4.5vmin;
}
const box = document.querySelector('.box');
let step = 0; // never wraps, so the box keeps turning the same way
document.querySelector('nav').addEventListener('click', (e) => {
const btn = e.target.closest('[data-dir]');
if (!btn) return;
step += Number(btn.dataset.dir);
box.style.setProperty('--step', step);
});