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.

  • 125 lines of CSS
  • 12 lines of HTML
  • 38 lines of JS
  • No dependencies
  • MIT licensed

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) * 42px)), 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.

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">
  <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 125 lines

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

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

Sass source 130 lines

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