CSS + JS Cards & galleries #pointer #cards #gallery #photo

Tilting photo wall in CSS + JavaScript

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.

  • 134 lines of CSS
  • 12 lines of HTML
  • 38 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. CSS cannot read the pointer, so JS does exactly two things on pointermove: write --rx / --ry for the board and a --lift between 0 and 1 on every photo. All motion is CSS.
  2. The pointer is measured against the wrapper, which never moves — only the board inside it tilts. Measuring the tilted board itself would change the numbers you are measuring, and the wall would wobble.
  3. A photo's lift is how close the pointer is to its centre, in tile sizes, eased with a smoothstep: 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.
  4. Each photo's shadow is a separate layer that stays on the board: as the photo rises, the shadow only fades in and slides away. That gap between them is what reads as height.
  5. One custom property sets every transition's duration: 0.14s while the pointer drives (it tracks tightly), 0.7s after it leaves, so everything eases home together.
  6. Every length is a multiple of one base unit, --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.

Key techniques

  • pointer → --rx / --ry on a static wrapper
  • per-tile --lift from distance → translateZ
  • measured on the untransformed wrapper (no feedback)
  • short transition while live, long ease back

Copy-paste code

HTML 12 lines

<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>

CSS 134 lines

.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; }

JS 38 lines

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);
Standalone snippet · plain CSS, no build step, no dependencies.