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.

  • 59 lines of CSS
  • 5 lines of HTML
  • 16 lines of JS
  • No dependencies
  • MIT licensed
GitHub

How it works

  1. Every layer has its own translateZ: moon far away (−300px), mountains, hills, trees in front (+70px).
  2. Pushing a layer back makes it look smaller, so far layers get a compensating scale() to keep filling the frame.
  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.

Key techniques

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

Copy-paste code

HTML 5 lines

<div class="view">
  <div class="world">
    <i></i><i></i><i></i><i></i>
  </div>
</div>

CSS 59 lines

.view {
  position: fixed;
  inset: 0;
  overflow: hidden;
  perspective: 500px;
  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: 70px;
  height: 70px;
  border-radius: 50%;
  background: #fff4d1;
  box-shadow: 0 0 40px #fff4d1;
  transform: translateZ(-300px) 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(-160px) 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(-40px) scale(1.08);
}

/* foreground trees */
.world i:nth-child(4) {
  background: #0d0820;
  clip-path: polygon(0 100%, 0 78%, 6% 78%, 10% 60%, 14% 78%, 80% 80%, 85% 58%, 90% 80%, 100% 80%, 100% 100%);
  transform: translateZ(70px) scale(0.9);
}

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

Sass source 78 lines

.d-parallax {
  position: absolute;
  inset: 0;
  overflow: hidden;
  perspective: 500px;
  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. While the pointer is driving, the transition is almost off:
    // a long one restarts on every pointer event and makes the scene lag behind the hand.
    transition: transform 0.7s cubic-bezier(0.2, 0.8, 0.2, 1);

    &.is-live {
      transition-duration: 0.1s;
    }
  }

  // Far layers are pushed back AND scaled up so they still fill the frame.
  i {
    position: absolute;
    inset: -20%;
  }

  // moon
  i:nth-child(1) {
    inset: 14% auto auto 62%;
    width: 46px;
    height: 46px;
    border-radius: 50%;
    background: #fff4d1;
    box-shadow: 0 0 40px #fff4d1;
    transform: translateZ(-300px) scale(1.6);
  }

  // far mountains
  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(-160px) scale(1.32);
  }

  // near hills
  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(-40px) scale(1.08);
  }

  // foreground trees
  i:nth-child(4) {
    background: #0d0820;
    clip-path: polygon(0 100%, 0 78%, 6% 78%, 10% 60%, 14% 78%, 80% 80%, 85% 58%, 90% 80%, 100% 80%, 100% 100%);
    transform: translateZ(70px) scale(0.9);
  }

  // The hint is a flat overlay, NOT part of the 3D world: inside the world, near the bottom edge
  // and close to the camera, perspective pushed it out of frame until the scene tilted.
  b {
    position: absolute;
    inset: auto 0 12px;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    text-align: center;
    text-shadow: 0 1px 6px rgb(0 0 0 / 0.7);
    pointer-events: none;
    transition: opacity 0.4s;
  }

  // once the visitor has moved the scene, the hint has done its job
  &.is-touched b {
    opacity: 0;
  }
}
Standalone snippet · plain CSS, no build step, no dependencies.