← Page transitions

Venetian Blinds

A page transition: when a link is clicked, a row of shaded slats tilts shut across the screen from top to bottom, and on the next page they tilt open again to reveal it. This page uses it, so try any link.

How to use it

  1. Add the snippet to every page of your site.
  2. Add data-venetian-blinds to each page’s <body>.

Options

Add any of these attributes to the same element.

data-venetian-blinds="#hex"Slat colour. Default #1e1b4b.
data-slatsHow many slats. Default 10.
data-direction="vertical"Upright slats that turn left to right.
data-durationTime for each slat to turn. Default 520 ms.
data-intervalContained mode only: time each slide stays. Default 2200 ms.

Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Coming back with the Back button opens the blinds again. Put data-venetian-blinds on any other element with two or more children and it becomes a slideshow.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Venetian Blinds: a page transition. When a link is clicked, a row of
    // slats tilts shut across the screen, top to bottom; on the next page they
    // tilt open again to reveal it.
    // Usage: <body data-venetian-blinds>   (add the snippet to every page)
    // Optional: data-venetian-blinds="#hex" (slat colour, default deep indigo),
    // data-slats (how many, default 10), data-direction="vertical" (upright
    // slats), data-duration (ms for each slat to turn, default 520).
    // Contained mode: put data-venetian-blinds on any other element with two or
    // more children and it blinds between them every data-interval ms.
    var DEFAULTS = { color: '#1e1b4b', slats: 10, duration: 520, interval: 2200 };

    var css =
      '.vbl-panel{position:fixed;inset:0;z-index:2147483000;pointer-events:none;visibility:hidden;perspective:1400px}' +
      '.vbl-slat{position:absolute;backface-visibility:hidden;' +
      'background-image:linear-gradient(var(--vbl-dir),rgba(255,255,255,.16),rgba(255,255,255,0) 45%,rgba(0,0,0,.22))}' +
      '.vbl-contained{position:relative;overflow:hidden;display:grid}' +
      '.vbl-contained>*:not(.vbl-panel){grid-area:1/1}' +
      '.vbl-contained>.vbl-panel{position:absolute}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function panel(parent, opts) {
      var p = document.createElement('div');
      p.className = 'vbl-panel';
      p.setAttribute('aria-hidden', 'true');
      var vertical = opts.vertical;
      p.style.setProperty('--vbl-dir', vertical ? 'to right' : 'to bottom');
      for (var i = 0; i < opts.slats; i++) {
        var s = document.createElement('div');
        s.className = 'vbl-slat';
        s.style.background = opts.color;
        // Each slat a hair bigger than its share, so closed blinds leave no seams.
        var at = (i * 100 / opts.slats) + '%', size = 'calc(' + (100 / opts.slats) + '% + 1px)';
        if (vertical) { s.style.left = at; s.style.width = size; s.style.top = 0; s.style.bottom = 0; }
        else { s.style.top = at; s.style.height = size; s.style.left = 0; s.style.right = 0; }
        p.appendChild(s);
      }
      parent.appendChild(p);
      p._vblSlats = Array.from(p.children);
      p._vblAxis = vertical ? 'rotateY' : 'rotateX';
      return p;
    }

    // Shut: slats turn from edge-on to flat. Open: they keep turning, edge-on again.
    function blinds(p, shut, duration, done) {
      if (p._vbl) p._vbl.pause();
      var from = {}, to = {};
      from[p._vblAxis] = shut ? 90 : 0;
      to[p._vblAxis] = shut ? 0 : -90;
      anime.utils.set(p._vblSlats, from);
      p.style.visibility = 'visible';
      var tl = anime.createTimeline({
        onComplete: function () { if (!shut) p.style.visibility = 'hidden'; if (done) done(); }
      });
      to.duration = duration;
      to.ease = shut ? 'out(3)' : 'inOut(3)';
      to.delay = anime.stagger(duration * 0.07);
      tl.add(p._vblSlats, to);
      p._vbl = tl;
      return duration * (1 + 0.07 * p._vblSlats.length);
    }

    function options(el) {
      return {
        color: el.getAttribute('data-venetian-blinds') || DEFAULTS.color,
        slats: Math.max(2, +el.dataset.slats || DEFAULTS.slats),
        vertical: el.dataset.direction === 'vertical'
      };
    }

    // Same-site links that should get the transition: not new tabs, downloads,
    // other sites or #anchors on this page.
    function linkFor(e) {
      var a = e.target.closest && e.target.closest('a[href]');
      if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
      if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
      var url = new URL(a.href, location.href);
      if (url.origin !== location.origin) return null;
      if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
      return url;
    }

    function pageMode(body) {
      var duration = +body.dataset.duration || DEFAULTS.duration;
      var p = panel(body, options(body));
      var trip = 0;
      blinds(p, false, duration);

      document.addEventListener('click', function (e) {
        var url = linkFor(e);
        if (!url) return;
        e.preventDefault();
        var t = ++trip, gone = false;
        var go = function () { if (gone || t !== trip) return; gone = true; location.href = url.href; };
        try {
          var total = blinds(p, true, duration, go);
          // Never hold up the visit: go anyway if the blinds stall.
          setTimeout(go, total + 400);
        } catch (err) { go(); }
      });
      // Coming back with the Back button can restore the page with the blinds
      // shut: open them, and cancel the visit that page was on its way to.
      window.addEventListener('pageshow', function (e) {
        if (!e.persisted) return;
        trip++;
        blinds(p, false, duration);
      });
    }

    function containedMode(el, reduced) {
      el.classList.add('vbl-contained');
      var slides = Array.from(el.children);
      var duration = +el.dataset.duration || DEFAULTS.duration;
      var p = panel(el, options(el));
      var i = 0;
      slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
      if (reduced) return;
      var shutFor = duration * (1 + 0.07 * p._vblSlats.length);
      // Shut, swap, open: on a timer that only runs while it's on screen.
      var timer = anime.createTimer({
        duration: (+el.dataset.interval || DEFAULTS.interval) + shutFor * 2,
        loop: true,
        autoplay: false,
        onLoop: function () {
          if (el.closest('[hidden]')) return;
          blinds(p, true, duration, function () {
            slides[i].style.visibility = 'hidden';
            i = (i + 1) % slides.length;
            slides[i].style.visibility = 'visible';
            setTimeout(function () { blinds(p, false, duration); }, 120);
          });
        }
      });
      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) timer.play(); else timer.pause();
      }).observe(el);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[data-venetian-blinds]').forEach(function (el) {
        if (el._vbl) return;
        el._vbl = true;
        if (el === document.body) { if (!reduced) pageMode(el); }
        else if (el.children.length > 1) containedMode(el, reduced);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<body data-venetian-blinds>
  …
</body>