Text Mask Transition
A giant word pops up with the next view showing through its letters, then rushes towards you until the new view fills the screen. As a page transition the screen closes in on the clicked link’s first word, and on the next page that word grows until you’re through it. This page uses it, so try any link.
How to use it
- Add the snippet to every page of your site.
- Add
data-text-mask-transitionto each page’s<body>.
Options
Add any of these attributes to the same element.
data-text-mask-transition="WORD" | Use one word for every page change. Default: the clicked link’s data-word, else its first word. |
data-color | Colour of the panel the word is cut from. Default #0f0b1a. |
data-duration | Time for each half of a page change. Default 800 ms (1400 ms for a whole slideshow change). |
data-words | Contained mode only: one word per child, separated by commas, e.g. AURORA,DUNES,LAKE. |
data-interval | Contained mode only: time each slide stays. Default 2200 ms. |
Short, chunky words work best: only the first word is used, cut to 14 letters. Links to other sites, new tabs, downloads and #anchors are left alone, and if anything goes wrong the link still opens. Put data-text-mask-transition on any other element with two or more children and each child grows out of a word, like the demo above.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Text Mask Transition: a giant word grows to fill the screen and becomes
// the window onto the next view. As a page transition, the screen closes in
// on a word cut out of a coloured panel when a link is clicked, and on the
// next page the word grows until the new page fills the screen.
// Usage: <body data-text-mask-transition> (add the snippet to every page)
// Optional: data-text-mask-transition="WORD" (one word used for every
// change; otherwise the clicked link's data-word, else its first word),
// data-color (panel colour, default #0f0b1a), data-duration (ms for each
// half on a page, default 800; for a whole slideshow change, default 1400).
// Contained mode: put data-text-mask-transition on any other element with
// two or more children. Every data-interval ms (default 2200) the next child
// appears inside a word that grows until it fills the box. Words come from
// data-words="ONE,TWO,THREE" (one per child), a child's data-word, or the
// attribute's own value.
var DEFAULTS = { color: '#0f0b1a', duration: 800, slideDuration: 1400, interval: 2200, word: 'NEXT' };
var KEY = 'animacious-text-mask';
var FONT = "'Arial Black','Helvetica Neue',Arial,sans-serif";
var css =
'html.tmt-pre body{opacity:0}' +
'.tmt-layer{position:fixed;inset:0;width:100%;height:100%;z-index:2147483000;pointer-events:none;display:block}' +
'.tmt-contained{position:relative;overflow:hidden;display:grid}' +
'.tmt-contained>*{grid-area:1/1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
var arriving = null;
try { arriving = sessionStorage.getItem(KEY); sessionStorage.removeItem(KEY); } catch (e) {}
function unhide() { document.documentElement.classList.remove('tmt-pre'); }
if (arriving && !reduced()) {
document.documentElement.classList.add('tmt-pre');
setTimeout(unhide, 3000);
}
function tween(duration, ease, paint, done) {
var s = { t: 0 };
paint(0);
return anime.animate(s, {
t: 1, duration: duration, ease: ease,
onUpdate: function () { paint(s.t); },
onComplete: function () { paint(1); if (done) done(); }
});
}
function clean(word) {
return String(word || '').trim().split(/\s+/)[0].replace(/[<>&"]/g, '').slice(0, 14).toUpperCase();
}
function linkFor(e) {
var a = e.target.closest && e.target.closest('a[href]');
if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return null;
if ((a.target && a.target !== '_self') || a.hasAttribute('download')) return null;
var url = new URL(a.href, location.href);
if (url.origin !== location.origin) return null;
if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
return { url: url, a: a };
}
// ---------- Page mode: a coloured panel with the word cut out of it ----------
var NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, attrs, parent) {
var n = document.createElementNS(NS, tag);
for (var k in attrs) n.setAttribute(k, attrs[k]);
if (parent) parent.appendChild(n);
return n;
}
function pageMode(body) {
var duration = +body.dataset.duration || DEFAULTS.duration;
var fixed = clean(body.getAttribute('data-text-mask-transition'));
var svg = svgEl('svg', { 'class': 'tmt-layer', 'aria-hidden': 'true' });
var mask = svgEl('mask', { id: 'tmt-page-mask', maskUnits: 'userSpaceOnUse', x: 0, y: 0 }, svgEl('defs', {}, svg));
var hole = svgEl('rect', { fill: '#fff' }, mask);
var text = svgEl('text', { fill: '#000', 'text-anchor': 'middle', 'dominant-baseline': 'central', 'font-weight': 900 }, mask);
text.style.fontFamily = FONT;
var panel = svgEl('rect', { fill: body.dataset.color || DEFAULTS.color, mask: 'url(#tmt-page-mask)' }, svg);
svg.style.visibility = 'hidden';
body.appendChild(svg);
var anim, trip = 0;
// Sizes the word to about four fifths of the screen's width.
function layout(word) {
var w = innerWidth, h = innerHeight;
[mask, hole, panel].forEach(function (n) { n.setAttribute('width', w); n.setAttribute('height', h); });
text.textContent = word;
text.setAttribute('x', w / 2);
text.setAttribute('y', h / 2);
text.setAttribute('font-size', 100);
var len = text.getComputedTextLength() || word.length * 75;
var size = Math.min(100 * w * 0.8 / len, h * 0.42);
text.setAttribute('font-size', size.toFixed(1));
// Far enough that a letter's stroke is wider than the screen.
return Math.max(w, h) / size * 9;
}
function paint(cx, cy, s, a) {
text.setAttribute('transform', 'translate(' + cx + ' ' + cy + ') scale(' + s.toFixed(3) + ') translate(' + -cx + ' ' + -cy + ')');
panel.setAttribute('opacity', a.toFixed(3));
}
// Leaving: the word closes in from huge as the panel fades up. Arriving:
// the word grows past you and the panel fades away.
function run(out, word, done) {
if (anim) anim.pause();
svg.style.visibility = 'visible';
var big = layout(word), cx = innerWidth / 2, cy = innerHeight / 2;
anim = tween(duration, out ? 'out(3)' : 'in(3)', function (t) {
var k = out ? 1 - t : t;
paint(cx, cy, Math.pow(big, k), Math.min(1, (1 - k) / 0.45));
}, function () {
if (!out) svg.style.visibility = 'hidden';
if (done) done();
});
}
if (arriving) run(false, fixed || clean(arriving) || DEFAULTS.word);
unhide();
document.addEventListener('click', function (e) {
var hit = linkFor(e);
if (!hit) return;
e.preventDefault();
var word = fixed || clean(hit.a.dataset.word) || clean(hit.a.textContent) || DEFAULTS.word;
var t = ++trip, gone = false;
var go = function () { if (gone || t !== trip) return; gone = true; location.href = hit.url.href; };
try { sessionStorage.setItem(KEY, word); } catch (err) {}
setTimeout(go, duration + 600);
// A short pause on the word before the page changes behind it.
try { run(true, word, function () { setTimeout(go, 180); }); } catch (err) { go(); }
});
window.addEventListener('pageshow', function (e) {
if (!e.persisted) return;
trip++;
try { sessionStorage.removeItem(KEY); } catch (err) {}
run(false, text.textContent || DEFAULTS.word);
});
}
// ---------- Contained mode: the next child seen through the word ----------
// The word as an SVG image for a CSS mask: black letters, stretched to an
// exact width so the mask can be sized without measuring.
function maskImage(word) {
var w = Math.max(1, word.length) * 72 + 8;
var svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ' + w + ' 100">' +
'<text x="4" y="86" font-family="' + FONT.replace(/'/g, '') + '" font-weight="900" font-size="100" textLength="' + (w - 8) + '" lengthAdjust="spacingAndGlyphs">' + word + '</text></svg>';
return { url: 'url("data:image/svg+xml,' + encodeURIComponent(svg) + '")', ratio: w / 100 };
}
function setMask(node, value, size) {
var props = { maskImage: value, maskSize: size, maskRepeat: 'no-repeat', maskPosition: 'center' };
for (var k in props) {
node.style[k] = props[k];
node.style['webkit' + k.charAt(0).toUpperCase() + k.slice(1)] = props[k];
}
}
function containedMode(el, still) {
el.classList.add('tmt-contained');
var slides = Array.from(el.children);
var duration = +el.dataset.duration || DEFAULTS.slideDuration;
var words = (el.dataset.words || '').split(',');
var i = 0;
slides.forEach(function (s, n) { s.style.visibility = n ? 'hidden' : 'visible'; });
if (still) return;
function wordFor(n) {
return clean(words[n]) || clean(slides[n].dataset.word) || clean(el.getAttribute('data-text-mask-transition')) || DEFAULTS.word;
}
var timer = anime.createTimer({
duration: (+el.dataset.interval || DEFAULTS.interval) + duration,
loop: true,
autoplay: false,
onLoop: function () {
if (el.closest('[hidden]')) return;
var W = el.clientWidth, H = el.clientHeight;
if (!W || !H) return;
var n = (i + 1) % slides.length, from = slides[i], to = slides[n];
var m = maskImage(wordFor(n));
// Starting width: most of the box, but never taller than half of it.
var w0 = Math.min(W * 0.86, H * 0.5 * m.ratio), big = W / w0 * 14;
from.style.zIndex = 0;
to.style.zIndex = 1;
to.style.visibility = 'visible';
tween(duration, 'linear', function (t) {
var s, a = 0;
if (t < 0.3) {
// The word pops up, with a little overshoot.
var u = t / 0.3, c = 1.7;
s = 0.5 + 0.5 * (1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2));
} else {
// Then rushes towards you, and its letters' gaps fill in at the end.
var v = (t - 0.3) / 0.7;
s = Math.pow(big, Math.pow(v, 2.2));
a = Math.max(0, (v - 0.5) / 0.5);
}
var fill = 'linear-gradient(rgba(0,0,0,' + a.toFixed(3) + '),rgba(0,0,0,' + a.toFixed(3) + '))';
setMask(to, m.url + ',' + fill, (w0 * s).toFixed(1) + 'px auto,100% 100%');
to.style.transform = 'scale(' + (1.25 - 0.25 * t).toFixed(4) + ')';
from.style.transform = 'scale(' + (1 + 0.1 * t).toFixed(4) + ')';
from.style.filter = 'brightness(' + (1 - 0.45 * Math.min(1, t * 3)).toFixed(3) + ')';
}, function () {
from.style.visibility = 'hidden';
from.style.transform = from.style.filter = from.style.zIndex = '';
to.style.transform = to.style.zIndex = '';
setMask(to, '', '');
i = n;
});
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) timer.play(); else timer.pause();
}).observe(el);
}
function init() {
var still = reduced();
document.querySelectorAll('[data-text-mask-transition]').forEach(function (el) {
if (el._tmt) return;
el._tmt = true;
if (el === document.body) { if (!still) pageMode(el); }
else if (el.children.length > 1) containedMode(el, still);
});
unhide();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><body data-text-mask-transition>
…
<a href="/work" data-word="WORK">See our work</a>
</body>