← Page transitions

Page Load Sequence

The header drops in, the headline rises word by word, then the copy, the button and the cards follow, each a beat after the last. One attribute choreographs a whole page’s entrance.

Acme
New season Fresh out of the oven

The header, the headline, the copy, the button and the cards, each a beat after the last.

Get started

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-page-load-sequence to the <body> (or any section).
  3. Mark the parts with data-enter in the order they should arrive: down, up, words, left, right, scale, fade or stagger (its children one after another).

Options

Add any of these attributes to the same element.

data-gapTime between one part starting and the next. Default 140 ms.
data-durationTime for each part. Default 750 ms.
data-loopReplay every few seconds.

Without any data-enter it uses the element’s own children: headers and navs drop in, an h1 goes word by word and the rest rises. Parts stay hidden until it plays (never more than four seconds, whatever happens). With reduced motion nothing is hidden or moved.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Page Load Sequence: the header drops in, the headline rises word by word,
    // then the rest of the page follows in a choreographed order, each part
    // starting a beat after the last.
    // Usage: <body data-page-load-sequence>   (or any section)
    // Mark the parts with data-enter, in the order they should arrive:
    //   down    drops in from above (good for a header)
    //   up      rises into place (the default)
    //   words   words rise one by one from behind a line (good for headlines)
    //   left, right   slides in from that side
    //   scale   grows in with a little overshoot (good for a button)
    //   fade    simply fades in
    //   stagger its children rise one after another (good for a row of cards)
    // Without any data-enter it uses the element's own children: headers and
    // navs drop in, h1s go word by word and everything else rises.
    // Optional: data-gap (ms between parts, default 140), data-duration (ms
    // for each part, default 750), data-loop (replay every few seconds).
    // Paste the snippet in the <head> so nothing flashes before it starts.
    var DEFAULTS = { gap: 140, duration: 750, hold: 2200 };
    var ATTR = 'data-page-load-sequence';

    var css =
      // Hidden until it plays, only for people who are fine with motion.
      '@media (prefers-reduced-motion: no-preference){' +
      '[' + ATTR + ']:not(.pgl-on) [data-enter],' +
      '[' + ATTR + ']:not(.pgl-on):not(:has([data-enter]))>*{opacity:0}}' +
      '.pgl-w{display:inline-block;overflow:hidden;vertical-align:top;padding-bottom:.12em;margin-bottom:-.12em}' +
      '.pgl-w>span{display:inline-block;transform-origin:0 100%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    // Never leave the page hidden, whatever happens to the scripts.
    setTimeout(function () {
      document.querySelectorAll('[' + ATTR + ']').forEach(function (el) { el.classList.add('pgl-on'); });
    }, 4000);

    // Wrap each word of the text (keeping any links or markup) in a mask.
    function words(el) {
      if (el._pglWords) return el._pglWords;
      var walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT), nodes = [], n;
      while ((n = walker.nextNode())) nodes.push(n);
      nodes.forEach(function (t) {
        var frag = document.createDocumentFragment();
        t.textContent.split(/(\s+)/).forEach(function (part) {
          if (!part) return;
          if (/^\s+$/.test(part)) { frag.appendChild(document.createTextNode(part)); return; }
          var w = document.createElement('span'), inner = document.createElement('span');
          w.className = 'pgl-w';
          inner.textContent = part;
          w.appendChild(inner);
          frag.appendChild(w);
        });
        t.parentNode.replaceChild(frag, t);
      });
      el._pglWords = Array.from(el.querySelectorAll('.pgl-w>span'));
      return el._pglWords;
    }

    function kindOf(el) {
      var k = el.getAttribute('data-enter');
      if (k) return k;
      if (/^(HEADER|NAV)$/.test(el.tagName)) return 'down';
      if (el.tagName === 'H1') return 'words';
      return 'up';
    }

    // The tween for one part: [targets, from-state, to-state].
    function move(el, kind, duration) {
      var d = { duration: duration, ease: 'out(4)' };
      switch (kind) {
        case 'down': return [el, { opacity: 0, y: -24 }, Object.assign({ opacity: 1, y: 0 }, d)];
        case 'left': return [el, { opacity: 0, x: -40 }, Object.assign({ opacity: 1, x: 0 }, d)];
        case 'right': return [el, { opacity: 0, x: 40 }, Object.assign({ opacity: 1, x: 0 }, d)];
        case 'fade': return [el, { opacity: 0 }, Object.assign({ opacity: 1 }, d, { ease: 'out(2)' })];
        case 'scale': return [el, { opacity: 0, scale: 0.6 }, Object.assign({ opacity: 1, scale: 1 }, d, { ease: 'outBack(1.8)' })];
        case 'words':
          return [words(el), { y: '110%', rotate: 6 }, Object.assign({ y: '0%', rotate: 0, delay: anime.stagger(duration * 0.09) }, d, { duration: duration * 1.1 })];
        case 'stagger':
          return [Array.from(el.children), { opacity: 0, y: 36, scale: 0.96 }, Object.assign({ opacity: 1, y: 0, scale: 1, delay: anime.stagger(duration * 0.14) }, d)];
        default: return [el, { opacity: 0, y: 28 }, Object.assign({ opacity: 1, y: 0 }, d)];
      }
    }

    function setup(el, reduced) {
      if (reduced) { el.classList.add('pgl-on'); return; }
      var d = el.dataset;
      var gap = d.gap !== undefined ? +d.gap : DEFAULTS.gap;
      var duration = +d.duration || DEFAULTS.duration;
      var marked = Array.from(el.querySelectorAll('[data-enter]'));
      var parts = (marked.length ? marked : Array.from(el.children)).filter(function (p) {
        return !/^(SCRIPT|STYLE|TEMPLATE|LINK|META)$/.test(p.tagName);
      });
      // For words and stagger the pieces inside move; the part itself stays visible.
      var holders = parts.filter(function (p) { return /^(words|stagger)$/.test(kindOf(p)); });
      var moves = parts.map(function (p) { return move(p, kindOf(p), duration); });
      var played = false;

      // Hold everything at its starting point (inline styles take over from
      // the stylesheet that hid it).
      function ready() {
        anime.utils.set(holders, { opacity: 1 });
        moves.forEach(function (m) { anime.utils.set(m[0], m[1]); });
        el.classList.add('pgl-on');
      }
      ready();

      function play() {
        ready();
        var tl = anime.createTimeline({ onComplete: d.loop !== undefined ? later : null });
        moves.forEach(function (m, i) { tl.add(m[0], Object.assign({}, m[2]), i * gap); });
      }

      // Looping: hold, fade the parts away together, then play again.
      function later() {
        setTimeout(function () {
          if (el.closest('[hidden]') || !visible) { waiting = true; return; }
          anime.animate(parts, { opacity: 0, duration: 450, ease: 'in(2)', onComplete: play });
        }, DEFAULTS.hold);
      }

      var visible = false, waiting = false;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        if (!visible) return;
        if (!played) { played = true; play(); }
        else if (waiting) { waiting = false; later(); }
      }, { threshold: el === document.body ? 0 : 0.25 }).observe(el);
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      document.querySelectorAll('[' + ATTR + ']').forEach(function (el) {
        if (el._pgl) return;
        el._pgl = true;
        setup(el, reduced);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<body data-page-load-sequence>
  <header data-enter="down">…</header>
  <h1 data-enter="words">Fresh out of the oven</h1>
  <p data-enter="up">…</p>
  <a class="button" data-enter="scale" href="/start">Get started</a>
  <div class="cards" data-enter="stagger">…</div>
</body>