← 3D & depth

Door Swing Reveal

A panelled door stands closed in its frame. Then it swings open on its hinges, towards you, and warm light spills out across the floor from the room behind it. Use it to reveal a message, a picture or a whole section, when it scrolls into view, on hover or on a click.

Come in

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-door-swing-reveal to a box with a door’s shape, e.g. aspect-ratio: 9 / 17.
  3. Put what’s behind the door inside it. Leave a little room round it for the frame.

Options

Add any of these attributes to the same element.

data-triggerview (default) opens it when scrolled into view; hover while pointed at, or on a tap; click opens and shuts it.
data-loopOpen and shut again and again.
data-holdHow long it stays open, and shut, when looping. Default 1800 ms.
data-angleHow far it opens, in degrees. Default 105.
data-hingeleft (default) or right.
data-colorColour of the door. Default #7c3aed.
data-durationTime to swing open. Default 1400 ms.

The door is drawn by the script, panels, knob and all; style what’s behind it however you like. With reduced motion it opens without swinging.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Door Swing Reveal: a panelled door in its frame swings open on its
    // hinges, towards you, to reveal whatever is behind it, with light
    // spilling out. Made with CSS 3D.
    // Usage:
    //   <div data-door-swing-reveal style="width: 160px; aspect-ratio: 9 / 17">
    //     <div>What's behind the door</div>
    //   </div>
    // The child fills the doorway.
    // Optional: data-trigger ("view", default: opens when scrolled into view;
    // "hover": opens while pointed at, or on a tap; "click": a tap opens and
    // shuts it), data-loop (open and shut forever), data-hold (ms it stays
    // open or shut when looping, default 1800), data-angle (how far it opens,
    // degrees, default 105), data-hinge ("left", default, or "right"),
    // data-color (the door, default #7c3aed), data-duration (ms, default 1400).
    var DEFAULTS = { hold: 1800, angle: 105, color: '#7c3aed', duration: 1400 };

    var css =
      '.dsr{position:relative;perspective:900px;-webkit-tap-highlight-color:transparent}' +
      '.dsr-way{position:absolute;inset:0;overflow:hidden;background:#140a26;box-shadow:inset 0 0 1.2em rgba(0,0,0,.6)}' +
      '.dsr-way>*:first-child{position:absolute;inset:0}' +
      '.dsr-frame{position:absolute;inset:calc(var(--dsr-f) * -1) calc(var(--dsr-f) * -1) 0;pointer-events:none;border:var(--dsr-f) solid #ede9fe;border-bottom:0;border-radius:3px 3px 0 0;' +
      'box-shadow:inset 0 0 0 1px rgba(76,29,149,.15),0 0 0 1px rgba(76,29,149,.12)}' +
      '.dsr-door{position:absolute;inset:0;transform-style:preserve-3d;pointer-events:none}' +
      '.dsr-door>*{position:absolute;box-sizing:border-box}' +
      '.dsr-front,.dsr-back{inset:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;border-radius:2px;background:var(--dsr-c)}' +
      '.dsr-back{transform:rotateY(180deg)}' +
      '.dsr-panel{position:absolute;left:16%;right:16%;border-radius:2px;box-shadow:inset .15em .15em .3em rgba(0,0,0,.35),inset -.15em -.15em .3em rgba(255,255,255,.25)}' +
      '.dsr-knob{position:absolute;top:52%;width:9%;aspect-ratio:1;border-radius:50%;background:radial-gradient(circle at 35% 35%,#fff7d6,#f5b83d 50%,#a16207);box-shadow:0 .1em .2em rgba(0,0,0,.4)}' +
      '.dsr-edge{top:0;bottom:0;width:var(--dsr-t);background:var(--dsr-c);filter:brightness(.7)}' +
      '.dsr-shade{position:absolute;inset:0;background:#000;opacity:0;pointer-events:none}' +
      '.dsr-light{position:absolute;left:0;right:0;top:100%;height:24%;pointer-events:none;opacity:0;transform-origin:50% 0;' +
      'background:linear-gradient(180deg,rgba(253,230,138,.55),rgba(253,230,138,0));clip-path:polygon(0 0,100% 0,135% 100%,-35% 100%)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function make(cls, parent) {
      var e = document.createElement('div');
      e.className = cls;
      parent.appendChild(e);
      return e;
    }

    function face(cls, door, right) {
      var f = make(cls, door);
      // Two raised panels and a knob on the side away from the hinges.
      var p1 = make('dsr-panel', f), p2 = make('dsr-panel', f);
      p1.style.top = '8%'; p1.style.height = '34%';
      p2.style.top = '50%'; p2.style.height = '42%';
      var knob = make('dsr-knob', f);
      knob.style[right ? 'left' : 'right'] = '7%';
      make('dsr-shade', f);
      return f;
    }

    function setup(el) {
      var d = el.dataset;
      el.classList.add('dsr');
      var way = document.createElement('div');
      way.className = 'dsr-way';
      while (el.firstChild) way.appendChild(el.firstChild);
      el.appendChild(way);
      var light = make('dsr-light', el);
      light.setAttribute('aria-hidden', 'true');
      var frame = make('dsr-frame', el);
      frame.setAttribute('aria-hidden', 'true');

      var rightHinge = d.hinge === 'right';
      var door = make('dsr-door', el);
      door.setAttribute('aria-hidden', 'true');
      door.style.transformOrigin = rightHinge ? '100% 50%' : '0 50%';
      el.style.setProperty('--dsr-c', d.color || DEFAULTS.color);
      var front = face('dsr-front', door, rightHinge);
      var back = face('dsr-back', door, !rightHinge);
      var edge = make('dsr-edge', door);
      edge.style[rightHinge ? 'left' : 'right'] = '0';
      edge.style.transformOrigin = rightHinge ? '0 50%' : '100% 50%';
      edge.style.transform = 'rotateY(' + (rightHinge ? -90 : 90) + 'deg)';
      var shadeF = front.lastChild, shadeB = back.lastChild;

      var angle = d.angle !== undefined ? +d.angle : DEFAULTS.angle;
      var duration = d.duration !== undefined ? +d.duration : DEFAULTS.duration;
      var hold = d.hold !== undefined ? +d.hold : DEFAULTS.hold;
      var s = { p: 0, open: false, anim: null };

      function size() {
        var w = el.clientWidth;
        el.style.setProperty('--dsr-f', Math.max(4, Math.round(w * 0.07)) + 'px');
        el.style.setProperty('--dsr-t', Math.max(3, Math.round(w * 0.05)) + 'px');
        door.style.fontSize = Math.max(6, w / 14) + 'px';
      }
      function draw() {
        var a = angle * s.p;
        // Opening towards you: the free edge swings out of the frame.
        door.style.transform = 'rotateY(' + (rightHinge ? a : -a) + 'deg)';
        shadeF.style.opacity = (Math.sin(Math.min(90, a) * Math.PI / 180) * 0.45).toFixed(3);
        shadeB.style.opacity = (0.5 - 0.3 * Math.min(1, s.p)).toFixed(3);
        light.style.opacity = Math.min(1, s.p * 1.4).toFixed(3);
        way.style.filter = 'brightness(' + (0.35 + 0.65 * Math.min(1, s.p * 1.3)).toFixed(3) + ')';
      }
      size();
      draw();
      if (window.ResizeObserver) new ResizeObserver(size).observe(el);

      function go(open) {
        if (open === s.open && !s.anim) return;
        s.open = open;
        el.setAttribute('aria-expanded', open);
        if (s.anim) s.anim.pause();
        s.anim = anime.animate(s, {
          p: open ? 1 : 0,
          duration: reduced() ? 0 : open ? duration : duration * 0.75,
          ease: open ? 'out(3)' : 'inOut(2)',
          onUpdate: draw,
          onComplete: function () { s.anim = null; }
        });
      }

      var trigger = d.trigger || 'view';
      var loop = d.loop !== undefined && d.loop !== 'false';
      var thumb = !!el.closest('[aria-hidden="true"]');
      var loopTimer = null, visible = false;
      function stopLoop() { if (loopTimer) { clearTimeout(loopTimer); loopTimer = null; } loop = false; }
      function cycle() {
        if (!loop) return;
        if (visible) go(!s.open);
        loopTimer = setTimeout(cycle, (s.open ? duration : duration * 0.75) + hold);
      }

      if (trigger === 'click' && !thumb) {
        el.style.cursor = 'pointer';
        el.setAttribute('role', 'button');
        if (!el.hasAttribute('tabindex')) el.tabIndex = 0;
        el.setAttribute('aria-expanded', 'false');
        el.addEventListener('click', function () { stopLoop(); go(!s.open); });
        el.addEventListener('keydown', function (e) {
          if (e.key !== 'Enter' && e.key !== ' ') return;
          e.preventDefault();
          stopLoop();
          go(!s.open);
        });
      } else if (trigger === 'hover' && !thumb) {
        var touch = false;
        el.addEventListener('pointerdown', function (e) { touch = e.pointerType !== 'mouse'; });
        el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { stopLoop(); go(true); } });
        el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') go(false); });
        el.addEventListener('click', function () { if (touch) { stopLoop(); go(!s.open); } });
      }

      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) return;
        if (trigger === 'view' && !loop && !s.open) go(true);
        if (loop && !loopTimer && !reduced()) loopTimer = setTimeout(cycle, 400);
      }, { threshold: 0.4 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-door-swing-reveal]').forEach(function (el) {
        if (el._dsr) return;
        el._dsr = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-door-swing-reveal style="width: 160px; aspect-ratio: 9 / 17">
  <div class="room">Come in</div>
</div>