Scroll Zoom-through
The section pins and scrolling zooms into its headline, faster and faster, until you pass right through one of the letters into the next section. The letters are windows onto what’s next from the start, so it glows through them before you dive in.
Scroll inside ↓
DIVE IN
You’re in.Welcome to the next section.
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-zoom-throughto a section holding a headline and, markeddata-next, the section you arrive in. - Pick the letter to dive through with
data-letter: one with a solid stroke in the middle, like I, H or E, works best.
Options
Add any of these attributes to the same element.
data-letter | Which letter to zoom into, counting from 1 and skipping spaces. Default the middle one. |
data-focus | Where in that letter to aim, as "x y" shares of its box. Default "0.5 0.5". |
data-length | How many screen heights of scrolling the zoom takes. Default 1.5. |
data-scale | How far it zooms. Default: far enough for the letter to fill the screen. |
data-theme | light: black letters on white (default), or dark: white letters on black. |
The headline is cut out of a white (or black) cover, so give it a heavy font and let the next section bring the colour. With reduced motion the headline and next section simply stack. It 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 () {
// Scroll Zoom-through: the section pins and scrolling zooms into its
// headline, faster and faster, until you pass through one of the letters
// into the next section. The letters are windows onto that next section
// from the start, so its colours show through them before you dive in,
// and its content fades up as you arrive.
// Usage: <section data-scroll-zoom-through>
// <h2>Dive in</h2>
// <div data-next>…the section you arrive in…</div>
// </section>
// Optional: data-letter (which letter to zoom into, counting from 1 and
// skipping spaces; default the middle one: pick one with a solid stroke
// in the middle, like I, H or E), data-focus (where in that letter to aim,
// "x y" as shares of its box, default "0.5 0.5"), data-length (how many
// screen heights of scrolling the zoom takes, default 1.5), data-scale
// (how far it zooms, default: enough for the letter to fill the screen),
// data-theme ("light": black letters on white, the default; "dark": white
// letters on black).
// The headline is the [data-headline] element, the first heading, or the
// first element inside that isn't [data-next].
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { length: 1.5, focus: '0.5 0.5' };
var css =
'.szt{position:relative}' +
'.szt-pin{position:sticky;top:0;overflow:hidden;isolation:isolate}' +
'.szt-next{position:absolute;inset:0;transform-origin:50% 50%}' +
'.szt-cover{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;text-align:center;background:#fff;mix-blend-mode:screen}' +
'.szt-cover>*{color:#000;margin:0}' +
'.szt-dark .szt-cover{background:#000;mix-blend-mode:multiply}' +
'.szt-dark .szt-cover>*{color:#fff}' +
'.szt-done .szt-cover{visibility:hidden}';
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;
}
// Wrap the nth non-space character of the headline in a span.
function wrapLetter(head, n) {
var walker = document.createTreeWalker(head, NodeFilter.SHOW_TEXT);
var all = [], node;
while ((node = walker.nextNode())) all.push(node);
var total = all.reduce(function (s, t) { return s + t.data.replace(/\s/g, '').length; }, 0);
if (!total) return null;
if (!n) n = Math.ceil(total / 2);
n = Math.max(1, Math.min(total, n));
for (var i = 0; i < all.length; i++) {
var t = all[i];
for (var j = 0; j < t.data.length; j++) {
if (/\s/.test(t.data[j])) continue;
if (--n) continue;
var rest = t.splitText(j);
rest.splitText(1);
var span = document.createElement('span');
span.className = 'szt-l';
rest.parentNode.insertBefore(span, rest);
span.appendChild(rest);
return span;
}
}
return null;
}
function setup(el) {
var d = el.dataset;
var next = el.querySelector('[data-next]');
var head = el.querySelector('[data-headline]') || el.querySelector('h1,h2,h3,h4,h5,h6');
if (!head) {
head = Array.prototype.filter.call(el.children, function (c) { return c !== next; })[0];
}
if (!head || !next) return;
var letter = wrapLetter(head, num(d.letter, 0));
if (!letter) return;
var focus = (d.focus || DEFAULTS.focus).split(/\s+/).map(Number);
var length = Math.max(0.2, num(d.length, DEFAULTS.length));
var box = scroller(el);
var pin = document.createElement('div');
pin.className = 'szt-pin';
var back = document.createElement('div');
back.className = 'szt-next';
var cover = document.createElement('div');
cover.className = 'szt-cover';
back.appendChild(next);
// The next section's own content fades up as you arrive.
var inner = Array.prototype.slice.call(next.children);
cover.appendChild(head);
pin.appendChild(back);
pin.appendChild(cover);
el.appendChild(pin);
el.classList.add('szt');
if (d.theme === 'dark') el.classList.add('szt-dark');
var viewH = 0, maxScale = 1, queued = false, last = -1;
function measure() {
viewH = box ? box.clientHeight : window.innerHeight;
pin.style.height = viewH + 'px';
el.style.height = viewH * (1 + length) + 'px';
// Measure the letter where it sits before any zoom.
cover.style.transform = '';
var c = cover.getBoundingClientRect(), l = letter.getBoundingClientRect();
var ox = l.left - c.left + l.width * (isNaN(focus[0]) ? 0.5 : focus[0]);
var oy = l.top - c.top + l.height * (isNaN(focus[1]) ? 0.5 : focus[1]);
cover.style.transformOrigin = ox + 'px ' + oy + 'px';
// Enough for the letter's stroke to cover the furthest corner.
maxScale = num(d.scale, 0) || (2.4 * Math.hypot(c.width, c.height)) / Math.max(4, l.width);
last = -1;
}
function update() {
queued = false;
var top = 0;
if (box) top = box.getBoundingClientRect().top + box.clientTop;
var p = (top - el.getBoundingClientRect().top) / (viewH * length);
p = Math.max(0, Math.min(1, p));
if (Math.abs(p - last) < 0.0002) return;
last = p;
// Hold briefly, zoom, and arrive with a little scroll to spare.
var t = Math.max(0, Math.min(1, (p - 0.04) / 0.8));
var e = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
// Scaling by a power keeps the zoom feeling steady as it speeds up.
var s = Math.pow(maxScale, e);
cover.style.transform = 'scale(' + s.toFixed(4) + ')';
back.style.transform = 'scale(' + (1.25 - 0.25 * e).toFixed(4) + ')';
var o = Math.max(0, Math.min(1, (t - 0.72) / 0.25)).toFixed(3);
inner.forEach(function (c) { c.style.opacity = o; });
el.classList.toggle('szt-done', t >= 1);
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
measure();
update();
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measure(); update(); });
if (window.ResizeObserver) new ResizeObserver(function () { measure(); update(); }).observe(pin);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(function () { measure(); update(); });
}
function init() {
// With reduced motion the headline and next section simply stay as written.
if (reduced()) return;
document.querySelectorAll('[data-scroll-zoom-through]').forEach(function (el) {
if (el._szt) return;
el._szt = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><section data-scroll-zoom-through data-letter="2">
<h2>DIVE IN</h2>
<div data-next>
<h2>You're in.</h2>
</div>
</section>