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) * 42 units)), so the nearest photo rises most and its neighbours a little.--u, tied to the canvas, text included, so the wall is the same share of a gallery card, the editor and a recording canvas. JS writes only angles and a 0-to-1 lift, never a length, so nothing it sets escapes the scale.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" tabindex="0" role="img" aria-label="Wall of photos that turns toward the pointer, the nearest photo lifting off it">
<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 {
/* one base unit: every length below is a multiple of it, so the wall is the same share of a
card, the editor, a full screen and a recording canvas */
--u: 0.37vmin;
display: grid;
place-items: center;
width: 100%;
height: 100%;
perspective: calc(800 * var(--u));
}
/* 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, calc(62 * var(--u)));
grid-auto-rows: calc(62 * var(--u));
gap: calc(9 * var(--u));
padding: calc(10 * var(--u));
border-radius: calc(14 * var(--u));
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: calc(1 * var(--u)) solid rgb(140 150 220 / 0.34);
border-radius: inherit;
background: rgb(20 24 48 / 0.7);
transform: translateZ(calc(-1 * var(--u))); /* 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: calc(6 * var(--u));
border-radius: calc(10 * var(--u));
background: rgb(0 0 0 / 0.3);
box-shadow: 0 0 calc(10 * var(--u)) calc(5 * var(--u)) rgb(0 0 0 / 0.3); /* static blur: only opacity and position move */
opacity: calc(var(--lift) * 0.9);
transform: translate3d(calc(var(--lift) * 5 * var(--u)), calc(var(--lift) * 9 * var(--u)), calc(1 * var(--u)));
transition: transform var(--t), opacity var(--t);
}
/* the photo: a white print with a gradient "picture" */
.cell i {
position: absolute;
inset: 0;
border: calc(3 * var(--u)) solid #fdfcf8;
border-radius: calc(7 * var(--u));
transform: translateZ(calc(2 * var(--u) + var(--lift) * 42 * var(--u))) 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: calc(4 * var(--u));
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 calc(2 * var(--u)), transparent calc(3 * var(--u))),
radial-gradient(circle at 62% 18%, #fff 0 calc(1.5 * var(--u)), transparent calc(2.5 * var(--u))),
radial-gradient(circle at 82% 52%, #fff 0 calc(2 * var(--u)), transparent calc(3 * var(--u))),
radial-gradient(circle at 70% 76%, #f4f1d0 0 calc(7 * var(--u)), transparent calc(8 * var(--u))),
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%);
}
/* the keyboard: Tab to it and it leans as it would for a pointer near the top right, even with
the pointer resting on the canvas (!important beats the pose the script writes inline) */
.scene:focus-visible .wall { --rx: 4deg !important; --ry: 6deg !important; }
.scene:focus-visible .cell { --lift: 0 !important; }
.scene:focus-visible .cell:nth-child(3) { --lift: 1 !important; }
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);