← Rotating words

Word Card Stack

The word in your sentence sits on the top card of a little stack. The card tips and falls away to show the next word underneath, the stack shuffles forward and a fresh card slides in at the back.

Plan your week

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the changing word in a <span> and list every word, separated by commas, in data-word-card-stack.

Options

Add any of these attributes to the same element.

data-holdHow long each word stays. Default 1800 ms.
data-durationTime for each card to fall. Default 800 ms.
data-falldown (default) drops the card, tipping to either side. left or right flicks it off that way.
data-cardThe cards’ colour. Default white.
data-depthHow many cards show behind the top one. Default 2.

The cards are as wide as the longest word, so the sentence never shifts. The stack peeks out a little below the line.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Word Card Stack: each word sits on a card in a little stack. The top card
    // tips and falls away to show the next word, the stack shuffles forward
    // and a fresh card slides in at the back.
    // Usage: <h1>Plan your <span data-word-card-stack="week, launch, trip">week</span></h1>
    // Optional: data-hold (ms per word, default 1800), data-duration (ms per
    // fall, default 800), data-fall ("down", the default, "left" or "right"),
    // data-card (card colour, default white), data-depth (cards visible
    // behind the top one, default 2).
    var DEFAULTS = { hold: 1800, duration: 800, fall: 'down', card: '#fff', depth: 2 };

    var css =
      '.wks{display:inline-block;position:relative;white-space:nowrap;vertical-align:baseline;text-align:center;padding:.04em .28em .1em;margin:0 .06em}' +
      '.wks-size{visibility:hidden;display:block}' +
      '.wks-card{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;padding-bottom:.06em;border-radius:.16em;' +
        'box-shadow:0 0 0 1px rgba(20,10,40,.1),0 .04em .14em rgba(20,10,40,.14);will-change:transform,opacity}' +
      '.wks-card>span{display:block}' +
      '.wks-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function span(cls, parent, text) {
      var s = document.createElement('span');
      s.className = cls;
      if (text) s.textContent = text;
      parent.appendChild(s);
      return s;
    }

    // Where a card sits at a given depth in the stack (0 = top).
    function pose(depth) {
      return { y: (depth * 0.13) + 'em', scale: 1 - depth * 0.07, opacity: depth > 0 ? 1 - depth * 0.18 : 1, rotate: 0, x: '0em' };
    }

    function card(s, word, depth) {
      var c = document.createElement('span');
      c.className = 'wks-card';
      c.setAttribute('aria-hidden', 'true');
      c.style.background = s.el.dataset.card || DEFAULTS.card;
      c.style.zIndex = 10 - depth;
      // Only the top card's word shows; the ones behind are blank backs.
      span('', c, word).style.opacity = depth ? 0 : 1;
      s.el.appendChild(c);
      anime.utils.set(c, pose(depth));
      return c;
    }

    function setup(el) {
      var words = (el.getAttribute('data-word-card-stack') || el.textContent)
        .split(',').map(function (w) { return w.trim(); }).filter(Boolean);
      el.classList.add('wks');
      el.textContent = '';
      span('wks-sr', el, words.join(', '));
      // An invisible copy of the longest word gives every card the same size,
      // so the sentence never shifts.
      var longest = words.reduce(function (a, b) { return b.length > a.length ? b : a; }, '');
      span('wks-size', el, longest).setAttribute('aria-hidden', 'true');
      var s = { el: el, words: words, cards: [], i: 0, visible: false, started: false };
      s.depth = Math.max(0, Math.min(4, el.dataset.depth !== undefined ? +el.dataset.depth : DEFAULTS.depth));
      var n = Math.min(words.length, s.depth + 1);
      for (var k = 0; k < n; k++) s.cards.push(card(s, words[k % words.length], k));
      // The stack peeks out below the line; leave room for it.
      el.style.marginBottom = (s.depth * 0.13) + 'em';
      return s;
    }

    function swap(s, done) {
      var d = s.el.dataset;
      var t = +d.duration || DEFAULTS.duration;
      var fall = d.fall || DEFAULTS.fall;
      var top = s.cards.shift();
      var side = fall === 'left' ? -1 : fall === 'right' ? 1 : (Math.random() < 0.5 ? -1 : 1);
      s.i = (s.i + 1) % s.words.length;
      // 1. The top card tips over and drops away.
      top.style.zIndex = 20;
      anime.animate(top, {
        y: fall === 'down' ? ['0em', '1.8em'] : ['0em', '0.6em'],
        x: fall === 'down' ? ['0em', side * 0.35 + 'em'] : ['0em', side * 2.2 + 'em'],
        rotate: [0, side * (fall === 'down' ? 16 : 28)],
        duration: t,
        ease: 'in(2.2)',
        onComplete: function () { top.remove(); }
      });
      anime.animate(top, { opacity: [1, 0], duration: t * 0.55, delay: t * 0.3, ease: 'linear' });
      // 2. The rest of the stack moves up a place, and the new top card's
      //    word comes into view.
      s.cards.forEach(function (c, k) {
        c.style.zIndex = 10 - k;
        anime.animate(c, Object.assign(pose(k), { duration: t * 0.75, delay: t * 0.2 + k * 50, ease: 'outBack(1.4)' }));
        if (k === 0) anime.animate(c.firstChild, { opacity: 1, duration: t * 0.4, delay: t * 0.15, ease: 'out(2)' });
      });
      // 3. A new card fades in at the back.
      var depth = s.cards.length;
      var c = card(s, s.words[(s.i + depth) % s.words.length], depth);
      anime.utils.set(c, { opacity: 0, y: (depth * 0.13 + 0.12) + 'em' });
      anime.animate(c, Object.assign(pose(depth), { duration: t * 0.7, delay: t * 0.45, ease: 'out(3)' }));
      s.cards.push(c);
      setTimeout(done, t + 100);
    }

    // Wait, then run fn, but only once the word is on screen.
    function wait(s, ms, fn) {
      setTimeout(function check() {
        if (s.visible && !document.hidden) fn();
        else setTimeout(check, 300);
      }, ms);
    }

    function start(s) {
      var hold = +s.el.dataset.hold || DEFAULTS.hold;
      (function loop() {
        wait(s, hold, function () { swap(s, loop); });
      })();
    }

    function init() {
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var io = new IntersectionObserver(function (entries) {
        entries.forEach(function (e) {
          var s = e.target._wks;
          s.visible = e.isIntersecting;
          if (s.visible && !s.started) { s.started = true; start(s); }
        });
      }, { threshold: 0.3 });
      document.querySelectorAll('[data-word-card-stack]').forEach(function (el) {
        if (el._wks) return;
        el._wks = setup(el);
        if (!reduced && el._wks.words.length > 1) io.observe(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<h1>
  Plan your
  <span data-word-card-stack="week, launch, trip">week</span>
</h1>