← Scroll

Scrollytelling Steps

A graphic pins in place while the steps of a story scroll past it, and each step changes what it shows: here a chart draws its line, flags the spike and adds a forecast. Side by side on wide screens; on phones the steps scroll over the graphic as cards.

Scroll inside ↓

Daily visitors Steady 4× 10k

SpringVisitors held steady at a few hundred a day.

Launch dayOne post went viral and traffic jumped fourfold.

Next yearIf it keeps growing, we’ll pass ten thousand a day.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-scrollytelling-steps to a container holding one data-graphic element and a data-step element for each step of the story.
  3. Inside the graphic, give anything that belongs to particular steps a data-show: 2, 2-3, 1,3 or 2+ (step 2 onwards). Add data-draw to SVG paths that should draw their line in.

Options

Add any of these attributes to the same element.

data-showOn elements in the graphic: the steps they appear for.
data-sideWhere the graphic sits on wide screens: left (default) or right.
data-stackBelow this width the steps scroll over the graphic as cards. Default 560 px.
data-offsetThe line in the view a step crosses to take over, from 0 (top) to 1 (bottom). Default 0.6.
data-dimOpacity of the steps that aren’t current. Default 0.3.

The container’s data-current holds the current step (from 1), so your CSS can change anything else by step, and it fires a scrollytelling:step event for your own code. The graphic pins with position: sticky, so nothing around it can have overflow: hidden. Works inside scrolling boxes too.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Scrollytelling Steps: a graphic stays pinned while text steps scroll past
    // it, and each step changes what the graphic shows. Side by side on wide
    // screens; on narrow ones the steps scroll over the pinned graphic as cards.
    // Usage: <div data-scrollytelling-steps>
    //          <div data-graphic>
    //            <svg>… <g data-show="2">shown from step 2</g> …</svg>
    //          </div>
    //          <div data-step>First step</div>
    //          <div data-step>Second step</div>
    //          <div data-step>Third step</div>
    //        </div>
    // Inside the graphic, data-show lists the steps an element appears for:
    // "2", "2-3", "1,3" or "2+" (step 2 onwards). Paths with data-draw draw
    // their line in as they appear. The container's data-current holds the
    // step number (from 1), so CSS can style anything else by step, and it
    // fires a "scrollytelling:step" event with { index, step } in its detail.
    // Optional: data-side ("left" or "right": where the graphic sits, default
    // left), data-stack (below this width in px the steps scroll over the
    // graphic, default 560), data-offset (the line in the view, 0 top to 1
    // bottom, that a step crosses to become current, default 0.6), data-dim
    // (opacity of the other steps, default 0.3).
    // Works inside scrolling boxes too: it follows the nearest scrolling parent.
    var DEFAULTS = { side: 'left', stack: 560, offset: 0.6, dim: 0.3 };

    var css =
      '.syt{display:grid;grid-template-columns:1fr 1fr;gap:0 6%;align-items:start}' +
      '.syt-pin{position:sticky;top:0;display:flex;align-items:center;justify-content:center;grid-row:1;grid-column:1;min-width:0}' +
      '.syt-steps{grid-row:1;grid-column:2;min-width:0;padding:calc(var(--syt-view)*.3) 0 calc(var(--syt-view)*.4)}' +
      '.syt-right .syt-pin{grid-column:2}.syt-right .syt-steps{grid-column:1}' +
      '.syt-step{min-height:calc(var(--syt-view)*.6);display:flex;flex-direction:column;justify-content:center;transition:opacity .45s ease}' +
      '.syt-over{grid-template-columns:1fr}' +
      '.syt-over .syt-pin,.syt-over .syt-steps{grid-column:1}' +
      '.syt-over .syt-steps{position:relative;z-index:1;padding-top:calc(var(--syt-view)*.6)}' +
      '.syt-over .syt-step{min-height:calc(var(--syt-view)*.7);opacity:1!important}' +
      ':where(.syt-over .syt-step)>*{background:rgba(255,255,255,.93);border-radius:14px;padding:12px 16px;box-shadow:0 8px 24px rgba(15,15,30,.14);-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}' +
      '.syt [data-show]{transform-box:fill-box;transform-origin:50% 50%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }

    function scroller(el) {
      for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
        var o = getComputedStyle(p).overflowY;
        if (o === 'auto' || o === 'scroll') return p;
      }
      return null;
    }

    // "2", "2-3", "1,3", "2+" -> function(step) -> boolean.
    function parseShow(s) {
      var parts = String(s).split(',');
      return function (n) {
        return parts.some(function (p) {
          p = p.trim();
          if (/\+$/.test(p)) return n >= +p.slice(0, -1);
          var m = p.split('-');
          return m.length === 2 ? n >= +m[0] && n <= +m[1] : n === +p;
        });
      };
    }

    function setup(el) {
      var d = el.dataset;
      var graphic = el.querySelector('[data-graphic]');
      var steps = Array.prototype.slice.call(el.querySelectorAll('[data-step]'));
      if (!graphic || !steps.length) return;
      var stack = num(d.stack, DEFAULTS.stack);
      var offset = num(d.offset, DEFAULTS.offset);
      var dim = num(d.dim, DEFAULTS.dim);
      var still = reduced();
      var box = scroller(el);

      var pin = document.createElement('div');
      pin.className = 'syt-pin';
      var col = document.createElement('div');
      col.className = 'syt-steps';
      el.insertBefore(pin, el.firstChild);
      pin.appendChild(graphic);
      el.insertBefore(col, pin.nextSibling);
      steps.forEach(function (s) { s.classList.add('syt-step'); col.appendChild(s); });
      el.classList.add('syt');
      if (d.side === 'right') el.classList.add('syt-right');

      var shows = Array.prototype.slice.call(graphic.querySelectorAll('[data-show]')).map(function (node) {
        var draws = Array.prototype.slice.call(node.querySelectorAll('[data-draw]'));
        if (node.hasAttribute('data-draw')) draws.push(node);
        return { node: node, test: parseShow(node.getAttribute('data-show')), draws: draws, on: null };
      });

      var current = 0, queued = false;

      function show(item, on, instant) {
        if (item.on === on) return;
        item.on = on;
        var n = item.node;
        anime.utils.remove(n);
        if (instant) {
          anime.utils.set(n, { opacity: on ? 1 : 0, scale: 1 });
          return;
        }
        if (on) {
          anime.animate(n, { opacity: [0, 1], scale: [0.94, 1], duration: 650, ease: 'outQuart' });
          item.draws.forEach(function (p) {
            if (!p.getTotalLength) return;
            var len = p.getTotalLength();
            anime.utils.remove(p);
            p.style.strokeDasharray = len;
            anime.animate(p, { strokeDashoffset: [len, 0], duration: 1100, ease: 'inOut(2)' });
          });
        } else {
          anime.animate(n, { opacity: 0, duration: 350, ease: 'out(2)' });
        }
      }

      function go(n, instant) {
        if (n === current) return;
        current = n;
        el.setAttribute('data-current', n);
        steps.forEach(function (s, i) {
          s.style.opacity = i === n - 1 ? 1 : dim;
          if (i === n - 1) s.setAttribute('aria-current', 'step');
          else s.removeAttribute('aria-current');
        });
        shows.forEach(function (item) { show(item, item.test(n), instant || still); });
        el.dispatchEvent(new CustomEvent('scrollytelling:step', { detail: { index: n, step: steps[n - 1] } }));
      }

      function measure() {
        var h = box ? box.clientHeight : window.innerHeight;
        el.style.setProperty('--syt-view', h + 'px');
        pin.style.height = h + 'px';
        el.classList.toggle('syt-over', el.clientWidth < stack);
      }

      function update() {
        queued = false;
        var top = 0, h = window.innerHeight;
        if (box) { var b = box.getBoundingClientRect(); top = b.top + box.clientTop; h = box.clientHeight; }
        var line = top + h * offset, n = 1;
        steps.forEach(function (s, i) {
          var r = s.getBoundingClientRect();
          // A step is current once its middle passes the line.
          if (r.top + r.height / 2 <= line) n = i + 1;
        });
        go(n);
      }

      function onScroll() {
        if (queued) return;
        queued = true;
        requestAnimationFrame(update);
      }

      measure();
      go(1, true);
      update();
      (box || window).addEventListener('scroll', onScroll, { passive: true });
      window.addEventListener('resize', function () { measure(); onScroll(); });
      if (window.ResizeObserver) new ResizeObserver(function () { measure(); onScroll(); }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-scrollytelling-steps]').forEach(function (el) {
        if (el._syt) return;
        el._syt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-scrollytelling-steps>
  <div data-graphic>
    <svg viewBox="0 0 300 200">
      <path data-show="1+" data-draw d="…"/>
      <circle data-show="2" cx="180" cy="46" r="9"/>
      <g data-show="3">…</g>
    </svg>
  </div>
  <div data-step><p>Visitors held steady all spring.</p></div>
  <div data-step><p>Then launch day sent them soaring.</p></div>
  <div data-step><p>Next year we&rsquo;ll pass ten thousand.</p></div>
</div>