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 ↓
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
- Paste the snippet just before
</head>. - Add
data-scrollytelling-stepsto a container holding onedata-graphicelement and adata-stepelement for each step of the story. - Inside the graphic, give anything that belongs to particular steps a
data-show:2,2-3,1,3or2+(step 2 onwards). Adddata-drawto SVG paths that should draw their line in.
Options
Add any of these attributes to the same element.
data-show | On elements in the graphic: the steps they appear for. |
data-side | Where the graphic sits on wide screens: left (default) or right. |
data-stack | Below this width the steps scroll over the graphic as cards. Default 560 px. |
data-offset | The line in the view a step crosses to take over, from 0 (top) to 1 (bottom). Default 0.6. |
data-dim | Opacity 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.
<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><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’ll pass ten thousand.</p></div>
</div>