CSS + JS Scenes & objects #pointer

Depth parallax in CSS + JavaScript

Layers sit at different translateZ depths; tilting the scene with the pointer makes them slide apart.

  • 68 lines of CSS
  • 5 lines of HTML
  • 16 lines of JS
  • No dependencies
  • Snippet under MIT
GitHub

How it works

  1. Every layer has its own translateZ: moon far away (−300 units), mountains, hills, trees in front (+70 units).
  2. Pushing a layer back makes it look smaller, so far layers get a compensating scale() to keep filling the frame: (perspective + depth) / perspective, so 1.6 for the moon at 300 units behind a 500-unit perspective.
  3. JS only rotates the world container a few degrees with the pointer. Real perspective then shifts near layers more than far ones — that is parallax, with no per-layer maths.
  4. Layers are oversized (inset: -20%) so their edges never show while tilting. That also means a shape placed at 90% of a layer sits past the edge of the canvas, so the trees are placed by where they land on it: the tree layer is drawn about 1.47 times the canvas width, and its trees stand at 23–31% and 66.5–74.5% of it.
  5. The view is inset: 0 and the layers are sized in percentages, so the scene fills the canvas edge to edge. Depths, the perspective and the moon are multiples of one base unit, --u, tied to the canvas, so the parallax is the same on a gallery card and on a full screen.

Key techniques

  • translateZ per layer
  • scale() to compensate for distance
  • pointer → two rotation variables

Copy-paste code

HTML 5 lines

<div class="view" tabindex="0" role="img" aria-label="Layers in depth that turn toward the pointer">
  <div class="world">
    <i></i><i></i><i></i><i></i>
  </div>
</div>

CSS 68 lines

.view {
  /* one base unit, tied to the canvas; the view itself fills the canvas edge to edge */
  --u: 0.33vmin;
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: calc(500 * var(--u));
  background: linear-gradient(#0c1033, #3a1d5e 60%, #ff7a59);
}

.world {
  position: absolute;
  inset: 0;
  transform-style: preserve-3d;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg));
  /* slow ease back to rest... */
  transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);
}

/* ...but nearly instant while the pointer is driving: a long transition restarts on every
   pointer event and makes the scene lag behind the hand */
.world.is-live {
  transition-duration: 0.1s;
}

.world i {
  position: absolute;
  inset: -20%;
}

/* moon */
.world i:nth-child(1) {
  inset: 14% auto auto 62%;
  width: calc(70 * var(--u));
  height: calc(70 * var(--u));
  border-radius: 50%;
  background: #fff4d1;
  box-shadow: 0 0 calc(40 * var(--u)) #fff4d1;
  transform: translateZ(calc(-300 * var(--u))) scale(1.6);
}

/* far mountains */
.world i:nth-child(2) {
  background: #4a2a73;
  clip-path: polygon(0 100%, 0 62%, 18% 44%, 34% 60%, 52% 38%, 70% 58%, 86% 42%, 100% 60%, 100% 100%);
  transform: translateZ(calc(-160 * var(--u))) scale(1.32);
}

/* near hills */
.world i:nth-child(3) {
  background: #2a1648;
  clip-path: polygon(0 100%, 0 70%, 22% 58%, 44% 72%, 66% 56%, 84% 70%, 100% 62%, 100% 100%);
  transform: translateZ(calc(-40 * var(--u))) scale(1.08);
}

/* foreground trees. The layer is oversized (-20% all round) and stands nearer, so it is drawn
   about 1.47 times wider than the canvas: a point at p% of it lands at 50 + 1.47 × (p − 50)% of
   the canvas. The two trees stand at 23–31% and 66.5–74.5% of it, which puts them well inside
   the canvas at every tilt instead of cut in half by its edges. */
.world i:nth-child(4) {
  background: #0d0820;
  clip-path: polygon(0 100%, 0 78%, 23% 78%, 27% 60%, 31% 78%, 66.5% 80%, 70.5% 58%, 74.5% 80%, 100% 80%, 100% 100%);
  transform: translateZ(calc(70 * var(--u))) scale(0.9);
}

/* 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) */
.view:focus-visible .world { --rx: -5deg !important; --ry: 8deg !important; }

JS 16 lines

const view = document.querySelector('.view');
const world = document.querySelector('.world');

view.addEventListener('pointermove', (e) => {
  const x = e.clientX / innerWidth - 0.5;    // -0.5 … 0.5
  const y = e.clientY / innerHeight - 0.5;
  world.style.setProperty('--ry', x * 30 + 'deg');
  world.style.setProperty('--rx', -y * 20 + 'deg');
  world.classList.add('is-live');
});

view.addEventListener('pointerleave', () => {
  world.classList.remove('is-live');
  world.style.removeProperty('--rx');
  world.style.removeProperty('--ry');
});
Standalone snippet · plain CSS, no build step, no dependencies.