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.
A gallery on the four sides of a cube. JS keeps one ever-growing angle, so Prev, Next and the dots always turn the short way round and never unwind.
Your edited version
index × -90deg, into --angle, and CSS turns the cube by it: rotateY(var(--angle)).((target − current + 4) % 4) gives 0-3 steps forward; if that is more than half way around, subtracting 4 makes it a few steps backward instead.translateZ(calc(-s / 2)) so whichever face is turned to the front sits exactly at z = 0 and renders at its true size, not shrunk by the perspective.transform is transitioned, so the turn runs on the compositor and stays smooth however fast you click.--u, tied to the canvas, so it is the same share of a gallery card, the editor and a recording canvas. The arrows, dots and caption are in plain vmin: the control zone is the same object, at the same size, in every model.accumulated --angle (never reset to 0)shortest path: ((target − current + 4) % 4), 3 → −1translateZ(−s/2) keeps the front at z = 0transition on transform<div class="cubenav">
<div class="view">
<div class="scene">
<div class="cube">
<i style="--hue:28">Dawn<small>01 / 04</small></i>
<i style="--hue:178">Reef<small>02 / 04</small></i>
<i style="--hue:300">Dusk<small>03 / 04</small></i>
<i style="--hue:240">Night<small>04 / 04</small></i>
<i></i>
<i></i>
</div>
</div>
</div>
<div class="controls">
<output class="caption" aria-live="polite">01 · Dawn</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"><path d="m15 18-6-6 6-6"/></svg></button>
<span class="dots">
<button type="button" data-go="0" aria-label="Go to Dawn"></button>
<button type="button" data-go="1" aria-label="Go to Reef"></button>
<button type="button" data-go="2" aria-label="Go to Dusk"></button>
<button type="button" data-go="3" aria-label="Go to Night"></button>
</span>
<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"><path d="m9 18 6-6-6-6"/></svg></button>
</div>
</div>
</div>
.cubenav {
/* one base unit: every length in the cube below is a multiple of it, so the carousel 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: 44vmin;
}
.scene {
position: relative;
display: grid;
place-items: center;
/* the floor shadow hangs below the cube: the room for it keeps the cube centred */
padding-bottom: calc(22 * var(--u));
perspective: calc(700 * var(--u));
}
/* a soft shadow on the floor, drawn before the cube so it is always underneath */
.scene::before {
content: '';
position: absolute;
bottom: 0;
left: calc(50% - 64 * var(--u));
width: calc(128 * var(--u));
height: calc(20 * var(--u));
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
}
.cube {
--s: calc(116 * var(--u));
position: relative;
width: var(--s);
height: var(--s);
transform-style: preserve-3d;
/* Read right to left: spin on the vertical axis by --angle (JS only ever adds or subtracts
90deg steps, it never wraps back to 0), tip the top toward you a little, then pull the cube
back by half a side so the front face sits at z = 0 and stays its true size. */
transform: translateZ(calc(var(--s) / -2)) rotateX(-9deg) rotateY(var(--angle, 0deg));
transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
}
/* turn each face outward, then push it half a side from the centre */
.cube > :nth-child(1) { transform: rotateY(0deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(2) { transform: rotateY(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(3) { transform: rotateY(180deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(4) { transform: rotateY(-90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(5) { transform: rotateX(90deg) translateZ(calc(var(--s) / 2)); }
.cube > :nth-child(6) { transform: rotateX(-90deg) translateZ(calc(var(--s) / 2)); }
/* each side is a little landscape photo: sky, sun, two hills, a gloss and a shade for the text */
.cube i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
padding: calc(10 * var(--u)) calc(11 * var(--u));
/* an inner line plus a soft shade instead of a hard border: squeezed side-on, a one-unit line
breaks up, the shade survives */
--edge: hsl(var(--hue) 90% 85% / 0.55);
--edge-soft: hsl(var(--hue) 90% 85% / 0.16);
box-shadow: inset 0 0 0 calc(1 * var(--u)) var(--edge), inset 0 0 calc(8 * var(--u)) var(--edge-soft);
border-radius: calc(10 * var(--u));
background:
linear-gradient(155deg, rgb(255 255 255 / 0.22), transparent 38%),
linear-gradient(transparent 52%, rgb(0 0 0 / 0.42)),
radial-gradient(95% 55% at 82% 108%, hsl(var(--hue) 45% 19%) 70%, transparent 71%),
radial-gradient(85% 50% at 16% 102%, hsl(var(--hue) 40% 33%) 70%, transparent 71%),
radial-gradient(circle at 70% 34%, hsl(calc(var(--hue) + 20) 100% 93%) 0 10%, hsl(calc(var(--hue) + 20) 100% 85% / 0.35) 11% 17%, transparent 18%),
linear-gradient(hsl(var(--hue) 80% 72%), hsl(calc(var(--hue) + 35) 70% 46%));
color: #fff;
font-size: calc(15 * var(--u));
font-style: normal;
font-weight: 800;
line-height: 1.1;
text-shadow: 0 calc(1 * var(--u)) calc(3 * var(--u)) rgb(0 0 0 / 0.4);
backface-visibility: hidden;
transform-style: preserve-3d;
}
/* rounded faces leave a hole where their corners meet: a plate just behind each face plugs it */
.cube i::before {
content: '';
position: absolute;
/* one unit short of the edge: full size, it would touch the next face and show there as a
dotted seam; much smaller leaves a channel along each edge you can see into */
inset: calc(1 * var(--u));
background: hsl(var(--hue) 45% 18%);
transform: translateZ(calc(-6 * var(--u)));
}
.cube i small {
font-size: calc(10 * var(--u));
font-weight: 600;
opacity: 0.85;
}
/* top and bottom: dark glass, so the photos are what you look at */
.cube i:nth-child(n + 5) {
--hue: 250;
--edge: rgb(140 150 220 / 0.34);
--edge-soft: rgb(140 150 220 / 0.16);
background:
linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
#221f45;
}
/* 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 cube's own unit. The caption is on its own line above the
row, and its line box never changes height, so a new slide cannot move the cube. */
.controls {
display: grid;
justify-items: center;
gap: 2vmin;
text-align: center;
}
.controls .caption {
font: 500 4.5vmin/1.2 Inter, system-ui, sans-serif;
letter-spacing: 0.06em;
opacity: 0.7;
}
.controls .row {
display: flex;
align-items: center;
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;
padding: 0;
}
.controls svg {
width: 4.5vmin;
height: 4.5vmin;
}
.dots {
display: flex;
align-items: center;
gap: 2vmin;
}
/* a dot is a small mark on a finger-sized button, not a small button */
.dots button {
background: none;
}
/* the marks are the stage's ink, faded by opacity (a colour mixed from currentColor can keep the
old theme's value on a live switch), so they read on both stages */
.dots button::before {
content: '';
width: 2vmin;
height: 2vmin;
border-radius: 999px;
background: currentColor;
opacity: 0.55;
transition: width 0.3s, background 0.3s, opacity 0.3s;
}
/* the current one is a longer pill, in the zone's violet: 3:1 and over on both stages */
.dots button[aria-current='true']::before {
width: 4.5vmin;
background: #6a45f5;
opacity: 1;
}
const cube = document.querySelector('.cube');
const row = document.querySelector('.controls .row');
const out = document.querySelector('output');
const dots = [...document.querySelectorAll('[data-go]')];
const slides = ['Dawn', 'Reef', 'Dusk', 'Night'];
const n = slides.length;
let index = 0; // unbounded: …, -1, 0, 1, 2, … 7, 8, …
function render() {
const current = ((index % n) + n) % n;
// face k sits at +90° × k around the cube, so showing it means turning by -90° × k
cube.style.setProperty('--angle', index * -90 + 'deg');
out.textContent = '0' + (current + 1) + ' · ' + slides[current];
dots.forEach((d, i) => d.setAttribute('aria-current', String(i === current)));
}
row.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
if (btn.dataset.dir) {
index += Number(btn.dataset.dir);
} else if (btn.dataset.go) {
const current = ((index % n) + n) % n;
let delta = (Number(btn.dataset.go) - current + n) % n; // 0…3 steps forward
if (delta > n / 2) delta -= n; // "3 forward" is really "1 back"
index += delta;
}
render();
});
render();