← 3D & depth

Diorama Box

A little night scene built inside an open box, like a shoebox diorama: a moonlit sky on the back wall, then hills, a row of pines, a cottage with a lit window and the grass at the front, each standing at its own depth. The box turns to follow your pointer, so you can peer in from the side and watch the layers slide past each other. On a phone, drag a finger across it.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-diorama-box to a box with a width and a height.
  3. Put your layers inside, back to front, each the full size of the box: the first is the back wall. Shape the others with clip-path or transparent images.

Options

Add any of these attributes to the same element.

data-depthOn a layer: where it stands, from 0 (the back wall) to 1 (the opening). Default evenly spaced.
data-deepHow deep the box is, as a share of its width. Default 0.55.
data-tiltHow far the box turns, in degrees. Default 22.
data-wallColour of the side walls and ceiling. Default #2e1065.
data-floorColour of the floor. Default #14532d.
data-frameColour of the rim round the opening, or false for none. Default white.
data-autoplay="false"Don’t sway when no one’s pointing at it.

Made with CSS 3D, so the layers can hold any HTML. It’s paused when off screen; with reduced motion the box stands still at a slight angle.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Diorama Box: a scene built inside an open-fronted box, like a shoebox
    // diorama. The layers stand at different depths between the back wall and
    // the opening, and the whole box turns to follow the pointer (or a finger
    // dragged across it), so you can peer in from the sides. Left alone it
    // sways gently by itself. Made with CSS 3D.
    // Usage:
    //   <div data-diorama-box style="width: 480px; aspect-ratio: 16 / 10">
    //     <div class="sky"></div>        <!-- the back wall -->
    //     <div class="hills"></div>
    //     <div class="trees"></div>
    //     <div class="grass"></div>      <!-- nearest the opening -->
    //   </div>
    // Children are layers, back to front, each the full size of the opening;
    // shape them with clip-path or transparent images. Add data-depth (0 at
    // the back to 1 at the opening) to place one yourself.
    // Optional: data-deep (box depth as a share of its width, default 0.55),
    // data-tilt (how far it turns, degrees, default 22), data-wall (colour of
    // the walls, default #2e1065), data-floor (default #14532d), data-frame
    // (colour of the rim, default #fff; "false" for none), data-autoplay="false"
    // (stay still until pointed at).
    var DEFAULTS = { deep: 0.55, tilt: 22, wall: '#2e1065', floor: '#14532d', frame: '#fff' };

    var css =
      '.drm{position:relative;perspective:var(--drm-p,900px);touch-action:pan-y}' +
      '.drm-box{position:absolute;inset:0;transform-style:preserve-3d}' +
      '.drm-box>*{position:absolute;box-sizing:border-box}' +
      '.drm-layer{inset:0;pointer-events:none}' +
      '.drm-floor{left:0;bottom:0;width:100%;transform-origin:50% 100%}' +
      '.drm-ceil{left:0;top:0;width:100%;transform-origin:50% 0}' +
      '.drm-left{left:0;top:0;height:100%;transform-origin:0 50%}' +
      '.drm-right{right:0;top:0;height:100%;transform-origin:100% 50%}' +
      '.drm-frame{inset:0;pointer-events:none;border-radius:4px}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function part(cls, box) {
      var p = document.createElement('div');
      p.className = cls;
      p.setAttribute('aria-hidden', 'true');
      box.appendChild(p);
      return p;
    }

    function setup(el) {
      var d = el.dataset;
      var kids = Array.from(el.children);
      if (!kids.length) return;
      el.classList.add('drm');
      var deep = +d.deep || DEFAULTS.deep;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var wall = d.wall || DEFAULTS.wall, floorColor = d.floor || DEFAULTS.floor;
      var box = document.createElement('div');
      box.className = 'drm-box';

      // Walls darken towards the back.
      var floor = part('drm-floor', box);
      var dark = function (deg, color) { return 'linear-gradient(' + deg + 'deg,rgba(0,0,0,0),rgba(0,0,0,.6)),' + color; };
      floor.style.background = dark(0, floorColor);
      var ceil = part('drm-ceil', box);
      var left = part('drm-left', box);
      var right = part('drm-right', box);
      ceil.style.background = dark(180, wall);
      left.style.background = dark(90, wall);
      right.style.background = dark(270, wall);

      var n = kids.length;
      var layers = kids.map(function (k, i) {
        k.classList.add('drm-layer');
        box.appendChild(k);
        var depth = k.dataset.depth !== undefined ? +k.dataset.depth : (n === 1 ? 0 : i / n * 0.95);
        return { el: k, depth: Math.max(0, Math.min(1, depth)) };
      });
      var frame = null;
      if (d.frame !== 'false') {
        frame = part('drm-frame', box);
        frame.style.boxShadow = 'inset 0 0 0 var(--drm-rim,6px) ' + (d.frame || DEFAULTS.frame) + ', 0 18px 40px -12px rgba(30,10,60,.5)';
      }
      el.appendChild(box);

      var W = 0, H = 0, D = 0;
      function size() {
        W = el.clientWidth;
        H = el.clientHeight;
        D = Math.round(W * deep);
        el.style.setProperty('--drm-p', Math.round(W * 2.2) + 'px');
        el.style.setProperty('--drm-rim', Math.max(3, Math.round(W / 70)) + 'px');
        floor.style.height = ceil.style.height = D + 'px';
        left.style.width = right.style.width = D + 'px';
        floor.style.transform = 'rotateX(90deg)';
        ceil.style.transform = 'rotateX(-90deg)';
        left.style.transform = 'rotateY(90deg)';
        right.style.transform = 'rotateY(-90deg)';
        layers.forEach(function (l) { l.el.style.transform = 'translateZ(' + (-D * (1 - l.depth)).toFixed(1) + 'px)'; });
      }

      // The view: where it is (x, y) and where it's heading (tx, ty), -1 to 1.
      var s = { x: 0.4, y: -0.3, tx: 0.4, ty: -0.3, t: 0, held: 0 };
      function draw() {
        // Turn round the middle of the box, not its opening.
        box.style.transform = 'translateZ(' + (-D / 2) + 'px) rotateX(' + (-s.y * tilt * 0.7).toFixed(2) + 'deg) rotateY(' + (s.x * tilt).toFixed(2) + 'deg) translateZ(' + D / 2 + 'px)';
      }
      size();
      draw();
      if (window.ResizeObserver) new ResizeObserver(function () { size(); draw(); }).observe(el);
      if (reduced()) return;

      var aim = function (e) {
        var r = el.getBoundingClientRect();
        // Point right and the box turns so you see in from the right.
        s.tx = Math.max(-1, Math.min(1, -(((e.clientX - r.left) / r.width) * 2 - 1)));
        s.ty = Math.max(-1, Math.min(1, -(((e.clientY - r.top) / r.height) * 2 - 1)));
        s.held = 2.5;
      };
      el.addEventListener('pointermove', function (e) { if (e.pointerType === 'mouse' || e.buttons) aim(e); });
      el.addEventListener('pointerdown', aim);
      var auto = d.autoplay !== 'false';

      var last = 0;
      var timer = anime.createTimer({
        loop: true,
        duration: 1000,
        autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(50, now - last) / 1000 : 0.016;
          last = now;
          if (s.held > 0) s.held -= dt;
          else if (auto) {
            s.t += dt * 0.5;
            s.tx = Math.sin(s.t) * 0.9;
            s.ty = Math.sin(s.t * 0.7 + 1) * 0.5;
          }
          var k = 1 - Math.pow(0.03, dt);
          s.x += (s.tx - s.x) * k;
          s.y += (s.ty - s.y) * k;
          draw();
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); } else timer.pause();
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-diorama-box]').forEach(function (el) {
        if (el._drm) return;
        el._drm = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-diorama-box style="width: 480px; aspect-ratio: 16 / 10">
  <div class="sky"></div>
  <div class="hills" data-depth="0.25"></div>
  <div class="trees" data-depth="0.5"></div>
  <div class="grass" data-depth="0.9"></div>
</div>