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.
Six photos on a board that leans toward your pointer, while the photo nearest to it lifts off the board and its neighbours rise a little less. JS only measures distances and writes numbers; CSS does all the motion.
Your edited version
pointermove: write --rx / --ry for the board and a --lift between 0 and 1 on every photo. All motion is CSS.t * t * (3 - 2 * t). CSS turns it into translateZ(calc(var(--lift) * 42px)), so the nearest photo rises most and its neighbours a little.pointer → --rx / --ry on a static wrapperper-tile --lift from distance → translateZmeasured on the untransformed wrapper (no feedback)short transition while live, long ease back<div class="scene">
<div class="wall">
<div class="board">
<div class="cell"><i></i></div>
<div class="cell"><i></i></div>
<div class="cell"><i></i></div>
<div class="cell"><i></i></div>
<div class="cell"><i></i></div>
<div class="cell"><i></i></div>
</div>
</div>
</div>
.scene {
display: grid;
place-items: center;
width: 100vw;
height: 100vh;
perspective: 800px;
}
/* the wrapper never moves: JS measures the pointer against it */
.wall {
--rx: 0deg; /* set from JS */
--ry: 0deg;
--t: 0.7s; /* long, soft ease back to rest… */
touch-action: none;
transform-style: preserve-3d;
}
.wall.is-live { --t: 0.14s; } /* …but tight while the pointer is driving */
/* the board: leans back a little at rest, then turns to face the pointer */
.board {
position: relative;
display: grid;
grid-template-columns: repeat(3, 62px);
grid-auto-rows: 62px;
gap: 9px;
padding: 10px;
border-radius: 14px;
transform-style: preserve-3d;
transform: rotateX(calc(12deg + var(--rx))) rotateY(var(--ry));
transition: transform var(--t) cubic-bezier(0.2, 0.8, 0.2, 1);
}
.board::before {
content: '';
position: absolute;
inset: 0;
border: 1px solid rgb(140 150 220 / 0.34);
border-radius: inherit;
background: rgb(20 24 48 / 0.7);
transform: translateZ(-1px); /* a hair behind the photos, never in their plane */
}
.cell {
--lift: 0; /* set from JS, 0…1 */
position: relative;
transform-style: preserve-3d;
}
/* the shadow stays on the board and spreads as the photo rises */
.cell::before {
content: '';
position: absolute;
inset: 6px;
border-radius: 10px;
background: rgb(0 0 0 / 0.3);
box-shadow: 0 0 10px 5px rgb(0 0 0 / 0.3); /* static blur: only opacity and position move */
opacity: calc(var(--lift) * 0.9);
transform: translate3d(calc(var(--lift) * 5px), calc(var(--lift) * 9px), 1px);
transition: transform var(--t), opacity var(--t);
}
/* the photo: a white print with a gradient "picture" */
.cell i {
position: absolute;
inset: 0;
border: 3px solid #fdfcf8;
border-radius: 7px;
transform: translateZ(calc(2px + var(--lift) * 42px)) scale(calc(1 + var(--lift) * 0.06));
transition: transform var(--t) cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* a sheen that brightens as the photo comes up */
.cell i::after {
content: '';
position: absolute;
inset: 0;
border-radius: 4px;
background: linear-gradient(125deg, rgb(255 255 255 / 0.5), transparent 45%);
opacity: var(--lift);
transition: opacity var(--t);
}
.cell:nth-child(1) i {
background:
radial-gradient(circle at 50% 72%, #fff3c4 0 8%, transparent 9%),
linear-gradient(180deg, #8b6cff 0%, #ff4d9d 55%, #ffb547 74%, #2a1840 75%);
}
.cell:nth-child(2) i {
background:
radial-gradient(circle at 74% 26%, #fff 0 7%, transparent 8%),
linear-gradient(180deg, #8fdcff 0 50%, #2ee6d6 51%, #0b5f78 100%);
}
.cell:nth-child(3) i {
background:
conic-gradient(from 148deg at 34% 34%, #3b2a78 0 64deg, transparent 0),
conic-gradient(from 144deg at 70% 48%, #5a3fb0 0 72deg, transparent 0),
linear-gradient(180deg, #ffd9a0, #ff4d9d);
}
.cell:nth-child(4) i {
background:
radial-gradient(circle at 26% 30%, #fff 0 2px, transparent 3px),
radial-gradient(circle at 62% 18%, #fff 0 1.5px, transparent 2.5px),
radial-gradient(circle at 82% 52%, #fff 0 2px, transparent 3px),
radial-gradient(circle at 70% 76%, #f4f1d0 0 7px, transparent 8px),
linear-gradient(180deg, #0b0d2a, #3a2a7a);
}
.cell:nth-child(5) i {
background:
radial-gradient(ellipse 90% 40% at 20% 100%, #c9761e 0 60%, transparent 61%),
radial-gradient(ellipse 80% 45% at 85% 96%, #e39a3b 0 60%, transparent 61%),
radial-gradient(circle at 70% 30%, #fff6d8 0 9%, transparent 10%),
linear-gradient(180deg, #ffcf7a, #ffb547);
}
.cell:nth-child(6) i {
background:
linear-gradient(170deg, transparent 30%, rgb(46 230 214 / 0.7) 38%, transparent 52%),
linear-gradient(160deg, transparent 44%, rgb(139 108 255 / 0.8) 54%, transparent 66%),
linear-gradient(180deg, #06142a 0 80%, #0c2a2a 80%);
}
const scene = document.querySelector('.scene');
const wall = document.querySelector('.wall');
const cells = [...wall.querySelectorAll('.cell')];
const COLS = 3;
const ROWS = 2;
const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
function move(e) {
// the wrapper never moves, so its box is a stable ruler: 0…1 across the wall
const r = wall.getBoundingClientRect();
const u = (e.clientX - r.left) / r.width;
const v = (e.clientY - r.top) / r.height;
// the board turns to face the pointer
wall.style.setProperty('--ry', clamp(u * 2 - 1, -1.4, 1.4) * 11 + 'deg');
wall.style.setProperty('--rx', clamp(1 - v * 2, -1.4, 1.4) * 9 + 'deg');
// each photo lifts by how close the pointer is to its centre, in tile sizes
cells.forEach((cell, i) => {
const dx = u * COLS - ((i % COLS) + 0.5);
const dy = v * ROWS - (Math.floor(i / COLS) + 0.5);
const t = clamp(1 - Math.hypot(dx, dy) / 1.25, 0, 1);
cell.style.setProperty('--lift', t * t * (3 - 2 * t)); // smoothstep
});
wall.classList.add('is-live');
}
function leave() {
wall.classList.remove('is-live'); // the long transition is back: all eases home
wall.style.removeProperty('--rx');
wall.style.removeProperty('--ry');
cells.forEach((cell) => cell.style.removeProperty('--lift'));
}
scene.addEventListener('pointerdown', move);
scene.addEventListener('pointermove', move);
scene.addEventListener('pointerleave', leave);
scene.addEventListener('pointercancel', leave);
$tile: 62px;
$gap: 9px;
// The wrapper never moves: JS measures the pointer against its box, so the tilt can never feed
// back into the measurement. JS writes --rx / --ry here and --lift (0…1) on each cell.
.d-tiltgallery {
--rx: 0deg;
--ry: 0deg;
--t: 0.7s; // long, soft ease back to rest…
touch-action: none; // a finger dragging over the wall moves it instead of scrolling the page
transform-style: preserve-3d;
&.is-live {
--t: 0.14s; // …but tight while the pointer is driving
}
// the board: leans back a little at rest, then turns to face the pointer
&__plane {
position: relative;
display: grid;
grid-template-columns: repeat(3, $tile);
grid-auto-rows: $tile;
gap: $gap;
padding: 10px;
border-radius: 14px;
transform-style: preserve-3d;
transform: rotateX(calc(12deg + var(--rx))) rotateY(var(--ry));
transition: transform var(--t) cubic-bezier(0.2, 0.8, 0.2, 1);
// the board itself, pushed a hair behind the photos so they never share its plane
&::before {
content: '';
position: absolute;
inset: 0;
border: 1px solid var(--border-strong);
border-radius: inherit;
background: color-mix(in srgb, var(--surface-solid) 70%, transparent);
transform: translateZ(-1px);
}
}
&__cell {
--lift: 0;
position: relative;
transform-style: preserve-3d;
// the photo's shadow stays on the board and spreads as the photo rises
&::before {
content: '';
position: absolute;
inset: 6px;
border-radius: 10px;
// a soft shadow: the blur is static, only its opacity and position change
background: rgb(0 0 0 / 0.3);
box-shadow: 0 0 10px 5px rgb(0 0 0 / 0.3);
opacity: calc(var(--lift) * 0.9);
transform: translate3d(calc(var(--lift) * 5px), calc(var(--lift) * 9px), 1px) scale(calc(1 + var(--lift) * 0.08));
transition:
transform var(--t) cubic-bezier(0.2, 0.8, 0.2, 1),
opacity var(--t);
}
// the photo: a white print with a layered-gradient "picture" as its background
i {
position: absolute;
inset: 0;
border: 3px solid #fdfcf8;
border-radius: 7px;
box-shadow: 0 1px 2px rgb(0 0 0 / 0.35);
transform: translateZ(calc(2px + var(--lift) * 42px)) scale(calc(1 + var(--lift) * 0.06));
transition: transform var(--t) cubic-bezier(0.2, 0.8, 0.2, 1);
// a sheen that brightens as it comes up toward the light
&::after {
content: '';
position: absolute;
inset: 0;
border-radius: 4px;
background: linear-gradient(125deg, rgb(255 255 255 / 0.5), transparent 45%);
opacity: var(--lift);
transition: opacity var(--t);
}
}
// six "photos", all gradients
&:nth-child(1) i {
background:
radial-gradient(circle at 50% 72%, #fff3c4 0 8%, transparent 9%),
linear-gradient(180deg, var(--accent) 0%, var(--hot) 55%, var(--warm) 74%, #2a1840 75%);
}
&:nth-child(2) i {
background:
radial-gradient(circle at 74% 26%, #fff 0 7%, transparent 8%),
linear-gradient(180deg, #8fdcff 0 50%, var(--accent-2) 51%, #0b5f78 100%);
}
&:nth-child(3) i {
background:
conic-gradient(from 148deg at 34% 34%, #3b2a78 0 64deg, transparent 0),
conic-gradient(from 144deg at 70% 48%, #5a3fb0 0 72deg, transparent 0),
linear-gradient(180deg, #ffd9a0, var(--hot));
}
&:nth-child(4) i {
background:
radial-gradient(circle at 26% 30%, #fff 0 2px, transparent 3px),
radial-gradient(circle at 62% 18%, #fff 0 1.5px, transparent 2.5px),
radial-gradient(circle at 82% 52%, #fff 0 2px, transparent 3px),
radial-gradient(circle at 40% 62%, #fff 0 1.5px, transparent 2.5px),
radial-gradient(circle at 70% 76%, #f4f1d0 0 7px, transparent 8px),
linear-gradient(180deg, #0b0d2a, #3a2a7a);
}
&:nth-child(5) i {
background:
radial-gradient(ellipse 90% 40% at 20% 100%, #c9761e 0 60%, transparent 61%),
radial-gradient(ellipse 80% 45% at 85% 96%, #e39a3b 0 60%, transparent 61%),
radial-gradient(circle at 70% 30%, #fff6d8 0 9%, transparent 10%),
linear-gradient(180deg, #ffcf7a, var(--warm));
}
&:nth-child(6) i {
background:
linear-gradient(170deg, transparent 30%, color-mix(in srgb, var(--accent-2) 70%, transparent) 38%, transparent 52%),
linear-gradient(160deg, transparent 44%, color-mix(in srgb, var(--accent) 80%, transparent) 54%, transparent 66%),
linear-gradient(180deg, #06142a 0 80%, #0c2a2a 80%);
}
}
}