← 3D & depth

Exploded View

A flat card tips back into 3D and its layers lift apart, like an engineer’s exploded drawing: the card, the image, the text and the button each float on their own level, with a tag naming each one. Move away and it all settles back flat.

Mountain hut
Book

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-exploded-view to a box whose children are the layers, bottom first.
  3. The first child sets the size; the rest lie on top of it, so give them transparent backgrounds. Name each layer with data-label.

Options

Add any of these attributes to the same element.

data-triggerhover (default; a tap on touch screens) or click.
data-autoplayMilliseconds: explode and settle on a timer until someone interacts.
data-gapSpace between the layers when apart, in px. Default 36.
data-tiltHow far it tips back, in degrees. Default 50.
data-turnHow far it turns, in degrees. Default -38.
data-durationTime to explode or settle. Default 900 ms.
data-labels="false"Hide the tags made from data-label.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Exploded View: a flat interface tips back into 3D and its layers lift
    // apart, so you can see how it stacks: background, image, text, button.
    // It explodes on hover (or a tap on touch screens) and settles back flat.
    // Usage:
    //   <div data-exploded-view>
    //     <div data-label="Card">…</div>      <!-- the bottom layer -->
    //     <div data-label="Image">…</div>
    //     <div data-label="Button">…</div>    <!-- the top layer -->
    //   </div>
    // The first child sets the size; the others lie on top of it, so give them
    // transparent backgrounds and place their content inside.
    // Optional: data-trigger ("hover", default, or "click"), data-autoplay (ms:
    // explode and settle on a timer until someone interacts), data-gap (px
    // between layers when apart, default 36), data-tilt (degrees it tips back,
    // default 56), data-turn (degrees it turns, default -38), data-duration
    // (ms, default 900), data-labels="false" (hide the data-label tags).
    var DEFAULTS = { gap: 36, tilt: 56, turn: -38, duration: 900 };

    var css =
      '.exv{perspective:1400px;-webkit-tap-highlight-color:transparent}' +
      '.exv-stack{position:relative;height:100%;transform-style:preserve-3d}' +
      '.exv-layer{transform-style:preserve-3d;box-sizing:border-box}' +
      '.exv-layer+.exv-layer{position:absolute;inset:0}' +
      '.exv-label{position:absolute;top:6px;right:6px;padding:2px 8px;border-radius:999px;background:rgba(17,12,34,.82);color:#fff;font:600 11px/1.6 system-ui,sans-serif;letter-spacing:0;white-space:nowrap;opacity:0;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function setup(el) {
      var d = el.dataset;
      var kids = Array.from(el.children);
      if (!kids.length) return;
      el.classList.add('exv');
      var stack = document.createElement('div');
      stack.className = 'exv-stack';
      var labels = [];
      kids.forEach(function (k) {
        k.classList.add('exv-layer');
        stack.appendChild(k);
        if (k.dataset.label && d.labels !== 'false') {
          var tag = document.createElement('span');
          tag.className = 'exv-label';
          tag.setAttribute('aria-hidden', 'true');
          tag.textContent = k.dataset.label;
          k.appendChild(tag);
          labels.push(tag);
        }
      });
      el.appendChild(stack);
      var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var turn = d.turn !== undefined ? +d.turn : DEFAULTS.turn;
      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      var s = el._exv = { p: 0, open: false, anim: null, timer: null };
      var n = kids.length;

      function draw() {
        var p = s.p;
        // Drop it a little as it explodes, so the raised stack stays centred.
        var lift = (n - 1) * gap * p * 0.42;
        stack.style.transform = 'translateY(' + lift + 'px) rotateX(' + tilt * p + 'deg) rotateZ(' + turn * p + 'deg) scale(' + (1 - 0.16 * p) + ')';
        kids.forEach(function (k, i) {
          // Upper layers lift a beat later than lower ones.
          var q = Math.max(0, Math.min(1, p * 1.25 - (i / Math.max(1, n - 1)) * 0.25));
          k.style.transform = 'translateZ(' + i * gap * q + 'px)';
        });
        labels.forEach(function (t) { t.style.opacity = Math.max(0, p * 2 - 1); });
      }

      function go(open) {
        if (open === s.open) return;
        s.open = open;
        if (s.anim) s.anim.pause();
        s.anim = anime.animate(s, {
          p: open ? 1 : 0,
          duration: reduced() ? 0 : duration,
          ease: open ? 'outBack(1.2)' : 'inOut(3)',
          onUpdate: draw
        });
      }
      s.go = go;
      draw();

      function stop() { if (s.timer) { clearInterval(s.timer); s.timer = null; } }

      if (d.trigger === 'click') {
        el.style.cursor = 'pointer';
        el.addEventListener('click', function () { stop(); go(!s.open); });
      } else {
        var touch = false;
        el.addEventListener('pointerdown', function (e) { touch = e.pointerType !== 'mouse'; });
        el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stop(); go(true); } });
        el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') go(false); });
        // Touch screens have no hover: a tap toggles it instead.
        el.addEventListener('click', function () { if (touch) { stop(); go(!s.open); } });
      }

      if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
        var every = +d.autoplay || 2600;
        var visible = false;
        new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(el);
        s.timer = setInterval(function () { if (visible) go(!s.open); }, every);
      }
    }

    function init() {
      document.querySelectorAll('[data-exploded-view]').forEach(function (el) {
        if (el._exv) return;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-exploded-view>
  <div class="card" data-label="Card"></div>
  <div data-label="Image"><img src="hut.jpg" alt=""></div>
  <div data-label="Text"><h3>Mountain hut</h3></div>
  <div data-label="Button"><button>Book</button></div>
</div>