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
rotateY(calc(index * -90deg)).((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.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="cube">
<i style="--hue:28">Dawn<small>01</small></i>
<i style="--hue:178">Reef<small>02</small></i>
<i style="--hue:300">Dusk<small>03</small></i>
<i style="--hue:240">Night<small>04</small></i>
<i></i>
<i></i>
</div>
</div>
<div class="bar">
<button type="button" data-dir="-1">Prev</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">Next</button>
<output>1 / 4 · Dawn</output>
</div>
</div>
.cubenav {
display: grid;
grid-template-rows: 1fr auto;
width: 320px;
height: 260px;
padding: 10px 14px;
}
.view {
display: grid;
place-items: center;
perspective: 700px;
}
.cube {
--s: 116px;
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
90° steps, it never wraps back to 0), tip the top toward the viewer a little, then pull the
whole 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(-14deg) 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)); }
.cube i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
padding: 10px;
border: 1px solid hsl(var(--hue) 90% 80% / 0.6);
border-radius: 10px;
background:
radial-gradient(circle at 72% 26%, rgb(255 255 255 / 0.55) 0 9%, transparent 10%),
linear-gradient(165deg, hsl(var(--hue) 85% 66%), hsl(calc(var(--hue) + 45) 70% 30%));
color: #fff;
font-size: 15px;
font-style: normal;
font-weight: 800;
line-height: 1.1;
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;
inset: 6px;
background: hsl(var(--hue) 60% 24%);
transform: translateZ(-6px);
}
.cube i small {
font-size: 10px;
font-weight: 600;
opacity: 0.8;
}
/* top and bottom caps */
.cube i:nth-child(n + 5) {
--hue: 250;
border-color: rgb(140 150 220 / 0.34);
background: color-mix(in srgb, #8b6cff 30%, #141830);
}
.bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.bar button {
padding: 5px 12px;
border: 0;
border-radius: 8px;
background: #8b6cff;
color: #fff;
font-weight: 800;
cursor: pointer;
}
.bar output {
margin-left: auto;
color: #949bc0;
white-space: nowrap;
}
.dots {
display: flex;
gap: 6px;
}
.dots button {
width: 10px;
height: 10px;
padding: 0;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: 50%;
background: transparent;
}
.dots button[aria-current='true'] {
border-color: #2ee6d6;
background: #2ee6d6;
}
const cube = document.querySelector('.cube');
const bar = document.querySelector('.bar');
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 = (current + 1) + ' / ' + n + ' · ' + slides[current];
dots.forEach((d, i) => d.setAttribute('aria-current', String(i === current)));
}
bar.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();
@use '../mixins' as *;
.d-cubenav {
display: grid;
grid-template-rows: 1fr auto;
width: 100%;
height: 100%;
padding: 10px 14px;
&__view {
display: grid;
place-items: center;
perspective: 700px;
}
&__cube {
--s: 116px;
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
// 90° steps, it never wraps back to 0), tip the top toward the viewer a little, then pull the
// whole 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(-14deg) rotateY(var(--angle, 0deg));
transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
@include cube-faces(var(--s));
i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
padding: 10px;
border: 1px solid hsl(var(--hue) 90% 80% / 0.6);
border-radius: 10px;
background:
radial-gradient(circle at 72% 26%, rgb(255 255 255 / 0.55) 0 9%, transparent 10%),
linear-gradient(165deg, hsl(var(--hue) 85% 66%), hsl(calc(var(--hue) + 45) 70% 30%));
color: #fff;
font-size: 15px;
font-style: normal;
font-weight: 800;
line-height: 1.1;
backface-visibility: hidden;
@include solid-core(6px, hsl(var(--hue) 60% 24%));
small {
font-size: 10px;
font-weight: 600;
opacity: 0.8;
}
// top and bottom caps
&:nth-child(n + 5) {
--hue: 250;
border-color: var(--border-strong);
background: color-mix(in srgb, var(--accent) 30%, var(--surface-solid));
}
}
}
&__bar {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
button {
padding: 5px 12px;
border: 0;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-weight: 800;
cursor: pointer;
}
output {
margin-left: auto;
color: var(--muted);
white-space: nowrap;
}
}
&__dots {
display: flex;
gap: 6px;
button {
width: 10px;
height: 10px;
padding: 0;
border: 1px solid var(--border-strong);
border-radius: 50%;
background: transparent;
&[aria-current='true'] {
border-color: var(--accent-2);
background: var(--accent-2);
}
}
}
}