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 / 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>
<output class="caption" aria-live="polite">01 · Dawn</output>
<div class="bar">
<button type="button" data-dir="-1" aria-label="Previous"><svg viewBox="0 0 24 24" width="16" height="16" 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" width="16" height="16" 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>
.cubenav {
display: grid;
grid-template-rows: 1fr auto auto;
justify-items: center;
gap: 8px;
width: 320px;
height: 280px;
padding: 10px 14px 12px;
font-family: system-ui, sans-serif;
}
.view {
position: relative;
display: grid;
place-items: center;
width: 100%;
perspective: 700px;
}
/* a soft shadow on the floor, drawn before the cube so it is always underneath */
.view::before {
content: '';
position: absolute;
bottom: calc(50% - 76px);
left: calc(50% - 64px);
width: 128px;
height: 20px;
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
}
.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
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: 9px 10px;
border: 1px solid hsl(var(--hue) 90% 85% / 0.55);
border-radius: 10px;
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: 15px;
font-style: normal;
font-weight: 800;
line-height: 1.1;
text-shadow: 0 1px 3px 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;
inset: 0; /* full size: a smaller plate leaves a channel along each edge you can see into */
background: hsl(var(--hue) 45% 18%);
transform: translateZ(-6px);
}
.cube i small {
font-size: 10px;
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;
border-color: rgb(140 150 220 / 0.34);
background:
linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
#221f45;
}
.caption {
color: #949bc0;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
}
/* one pill: previous, dots, next */
.bar {
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: 999px;
background: rgb(7 8 15 / 0.7);
}
.bar > button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: linear-gradient(135deg, #8b6cff, #ff4d9d);
color: #fff;
cursor: pointer;
}
.dots {
display: flex;
align-items: center;
gap: 6px;
}
.dots button {
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: rgb(140 150 220 / 0.34);
cursor: pointer;
}
/* the current one is a longer pill */
.dots button[aria-current='true'] {
width: 18px;
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 = '0' + (current + 1) + ' · ' + 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 *;
// A photo gallery on the four sides of a cube: the cube, a caption, and one pill of controls.
.d-cubenav {
display: grid;
grid-template-rows: 1fr auto auto;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
padding: 10px 14px 14px; // the control dock: the same place in every demo with controls
&__view {
position: relative;
display: grid;
place-items: center;
width: 100%;
perspective: 700px;
// a soft shadow on the floor, drawn before the cube so it is always underneath
&::before {
content: '';
position: absolute;
bottom: calc(50% - 76px);
left: calc(50% - 64px);
width: 128px;
height: 20px;
border-radius: 50%;
background: radial-gradient(closest-side, rgb(0 0 0 / 0.45), transparent);
}
}
&__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(-9deg) rotateY(var(--angle, 0deg));
transition: transform 0.8s cubic-bezier(0.3, 1.2, 0.5, 1);
@include cube-faces(var(--s));
// each side is a little landscape photo: sky, sun, two hills, a gloss and a shade for the text
i {
position: absolute;
inset: 0;
display: grid;
align-content: end;
padding: 9px 10px;
border: 1px solid hsl(var(--hue) 90% 85% / 0.55);
border-radius: 10px;
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: 15px;
font-style: normal;
font-weight: 800;
line-height: 1.1;
text-shadow: 0 1px 3px rgb(0 0 0 / 0.4);
backface-visibility: hidden;
@include solid-core(6px, hsl(var(--hue) 45% 18%));
small {
font-size: 10px;
font-weight: 600;
opacity: 0.85;
}
// top and bottom: dark glass, so the photos are what you look at
&:nth-child(n + 5) {
--hue: 250;
border-color: var(--border-strong);
background:
linear-gradient(135deg, rgb(255 255 255 / 0.12), transparent 60%),
color-mix(in srgb, var(--accent) 18%, var(--surface-solid));
}
}
}
&__caption {
color: var(--muted);
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
}
// one pill: ‹ dots ›
&__bar {
display: flex;
align-items: center;
gap: 10px;
padding: 4px;
border: 1px solid var(--border-strong);
border-radius: 999px;
background: color-mix(in srgb, var(--bg) 70%, transparent);
> button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--hot));
color: #fff;
cursor: pointer;
--icon: 16px;
}
}
&__dots {
display: flex;
align-items: center;
gap: 6px;
button {
width: 8px;
height: 8px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--border-strong);
cursor: pointer;
// the current one is a longer pill (changed at once, not animated: width is not a transform)
&[aria-current='true'] {
width: 18px;
background: var(--accent-2);
}
}
}
}