← 3D & depth

Pop-up Card

A birthday card lies on the table. It opens, and as the back swings up a paper scene unfolds inside: the hills stand up first, then the trees and the little house, then the grass at the front, each layer rising on its own crease just like a real pop-up card.

For you
Happy birthday!

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pop-up-card to a box the size of one half of the card. Leave room above it: the open card stands about one and a half times as tall.
  3. Its first child is the cover and its second the inside of the back. Every other child is a pop-up layer: give each a data-at (0 at the fold to 1 at the front edge) and a left, width and height in %.

Options

Add any of these attributes to the same element.

data-loopClose the card and open it again, over and over.
data-durationHow long the card takes to open. Default 1500 ms.
data-holdHow long it stays open before closing, when looping. Default 2400 ms.
data-tiltHow far you look down on the table, in degrees. Default 50.
data-paperColour of the inside of the bottom half. Default #fffaf3.

Keep each layer’s height under (1 − data-at) of the box, so it folds away inside the closed card. Shape layers with clip-path or SVG. Without data-loop it opens once when scrolled into view, and a click or tap shuts and reopens it. With reduced motion it’s simply open.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pop-up Card: a greeting card lying on a table opens, and a paper pop-up
    // scene stands up inside it, layer by layer, like a real pop-up book.
    // Made with CSS 3D.
    // Usage:
    //   <div data-pop-up-card style="width: 300px; height: 190px">
    //     <div>Cover</div>
    //     <div>Inside of the back</div>
    //     <div data-at="0.1" style="left: 0; width: 100%; height: 70%">A layer</div>
    //     <div data-at="0.7" style="left: 0; width: 100%; height: 25%">Another</div>
    //   </div>
    // The box is one half of the card. The first child is the cover, the
    // second the inside of the back half, and every other child is a pop-up
    // layer standing on the bottom half. data-at says how far from the fold it
    // stands (0 at the fold, 1 at the edge); give it a left, width and height
    // (as a share of the box), and keep its height under (1 - data-at) of the
    // box so it folds away inside the card.
    // Optional: data-loop (close and open again), data-duration (ms to open,
    // default 1500), data-hold (ms it stays open, default 2400), data-tilt
    // (degrees you look down on the table, default 50), data-paper (colour of
    // the inside of the bottom half, default #fffaf3).
    // When it isn't looping, a click or tap closes and opens it.
    var DEFAULTS = { duration: 1500, hold: 2400, tilt: 50, paper: '#fffaf3' };

    var css =
      '.puc{position:relative;perspective:1000px;perspective-origin:50% 0;-webkit-tap-highlight-color:transparent}' +
      '.puc-table{position:absolute;inset:0;transform-style:preserve-3d}' +
      '.puc-table>*{position:absolute;box-sizing:border-box}' +
      '.puc-base{inset:0;border-radius:0 0 4px 4px;box-shadow:0 14px 30px -8px rgba(40,10,80,.35)}' +
      '.puc-back{left:0;right:0;top:-100%;height:100%;transform-origin:50% 100%;transform-style:preserve-3d}' +
      '.puc-back>*{position:absolute;inset:0;box-sizing:border-box;overflow:hidden;border-radius:4px 4px 0 0;backface-visibility:hidden;-webkit-backface-visibility:hidden}' +
      '.puc-cover{transform:rotateX(180deg)}' +
      '.puc-layer{transform-origin:50% 100%}' +
      '.puc-shade{position:absolute;inset:0;pointer-events:none;background:#1e0b3a}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function clamp(v) { return v < 0 ? 0 : v > 1 ? 1 : v; }
    function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }

    function shade(parent) {
      var s = document.createElement('div');
      s.className = 'puc-shade';
      s.setAttribute('aria-hidden', 'true');
      parent.appendChild(s);
      return s;
    }

    function setup(el) {
      var d = el.dataset;
      var kids = Array.from(el.children);
      if (kids.length < 2) return;
      el.classList.add('puc');
      var tilt = d.tilt !== undefined ? +d.tilt : DEFAULTS.tilt;
      var table = document.createElement('div');
      table.className = 'puc-table';
      var base = document.createElement('div');
      base.className = 'puc-base';
      base.style.background = d.paper || DEFAULTS.paper;
      table.appendChild(base);

      var layers = kids.slice(2).map(function (k, i, all) {
        var at = k.dataset.at !== undefined ? clamp(+k.dataset.at) : 0.1 + 0.8 * i / Math.max(1, all.length);
        k.classList.add('puc-layer');
        k.style.bottom = (1 - at) * 100 + '%';
        table.appendChild(k);
        // Layers near the fold stand up first, as the card opens.
        return { el: k, at: at, sh: shade(k) };
      });

      var back = document.createElement('div');
      back.className = 'puc-back';
      kids[1].classList.add('puc-inside');
      kids[0].classList.add('puc-cover');
      back.appendChild(kids[1]);
      back.appendChild(kids[0]);
      var backShade = shade(kids[1]);
      table.appendChild(back);
      el.appendChild(table);

      // p runs from 0 (closed) to 1 (open).
      var s = { p: 0 };
      function draw() {
        var p = s.p;
        table.style.transform = 'translateY(' + (4 + 36 * p) + '%) rotateX(' + tilt + 'deg) rotateZ(' + (-18 + 30 * p) + 'deg)';
        // The back half swings up from lying on the front to leaning back a little.
        back.style.transform = 'translateZ(3px) rotateX(' + (-180 + 96 * p) + 'deg)';
        backShade.style.opacity = (0.35 * (1 - p)).toFixed(3);
        layers.forEach(function (l) {
          var q = ease(clamp((p - 0.12 - l.at * 0.25) / 0.6));
          l.el.style.transform = 'rotateX(' + (-180 + 90 * q) + 'deg)';
          l.el.style.visibility = q > 0 ? 'visible' : 'hidden';
          l.sh.style.opacity = (0.45 * (1 - q)).toFixed(3);
        });
        el.setAttribute('aria-expanded', p > 0.5);
      }

      var loop = d.loop !== undefined && d.loop !== 'false';
      if (reduced()) { s.p = 1; draw(); return; }
      draw();

      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var tl = null;
      function play() {
        if (tl) tl.pause();
        tl = anime.createTimeline({ loop: loop, loopDelay: 700, onUpdate: draw });
        if (s.p > 0) tl.add(s, { p: 0, duration: duration * 0.7, ease: 'inOut(3)' });
        tl.add(s, { p: 1, duration: duration, ease: 'inOut(2)' }, s.p > 0 ? '+=400' : 200);
        if (loop) tl.add(s, { p: 0, duration: duration * 0.7, ease: 'inOut(3)' }, '+=' + hold);
      }
      el._puc = {
        start: function () { if (!tl) play(); else if (loop) tl.play(); },
        stop: function () { if (tl && loop) tl.pause(); }
      };
      if (!loop) {
        el.style.cursor = 'pointer';
        el.addEventListener('click', function () { if (tl && tl.completed) play(); });
      }
    }

    function init() {
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var c = e.target._puc;
          if (!c || c === true) return;
          if (e.isIntersecting) c.start(); else c.stop();
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-pop-up-card]').forEach(function (el) {
        if (el._puc) return;
        el._puc = true;
        setup(el);
        io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pop-up-card data-loop style="width: 300px; height: 190px">
  <div class="cover">For you</div>
  <div class="sky">Happy birthday!</div>
  <div class="hills" data-at="0.06" style="left: 0; width: 100%; height: 72%"></div>
  <div class="house" data-at="0.4" style="left: 36%; width: 28%; height: 48%"></div>
  <div class="grass" data-at="0.72" style="left: 0; width: 100%; height: 22%"></div>
</div>