Shape Mask Reveal
The text is uncovered by a shape growing out from a point: a circle, a wobbling blob, a diamond, a star, or an image of your own. Give it a list of shapes and it takes the next one each time it loops.
Grow into view
How to use it
- Paste the snippet just before
</head>. - Add
data-shape-mask-revealto any heading, text element or image.
Options
Add any of these attributes to the same element.
data-shape | circle (default), blob, diamond or star. A space-separated list takes the next shape on each loop. |
data-image | The URL of your own shape (an SVG or PNG with transparency), used instead of data-shape. |
data-origin | Where the shape grows from, as x% y%. Default 50% 50%. |
data-spin | Degrees the shape turns as it grows. Default 0. |
data-loop | Grow, hold, shrink away and grow again. |
data-hold | How long the text stays before shrinking away, when looping. Default 1600 ms. |
data-duration | Time to grow. Default 1300 ms. |
data-delay | Wait before starting. Default 0 ms. |
The shape grows until it covers the whole element, so it reads best on text with a background, like a badge or a banner.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Shape Mask Reveal: text is uncovered by a shape growing out from a point:
// a circle, a wobbling blob, a diamond, a star, or your own image.
// Usage: <h1 data-shape-mask-reveal>Grow into view</h1>
// Optional: data-shape ("circle", the default, "blob", "diamond" or "star";
// a space-separated list takes the next shape on each loop), data-image (a
// URL of your own shape, e.g. an SVG, used instead of data-shape), data-origin
// (where it grows from, as "x% y%", default "50% 50%"), data-spin (degrees the
// shape turns as it grows, default 0), data-loop (shrink away and grow again),
// data-hold (ms to stay before shrinking), data-duration (ms to grow),
// data-delay (ms before starting).
var DEFAULTS = { shape: 'circle', origin: '50% 50%', spin: 0, duration: 1300, hold: 1600, delay: 0 };
function reduced() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
// Each shape as a radius for an angle (1 = the circle it fits round), and how
// far it must grow before its narrowest part covers that circle.
var SHAPES = {
blob: {
cover: 1.35,
r: function (a, t) {
return 1 + 0.13 * Math.sin(3 * a + t * 2.1) + 0.09 * Math.sin(5 * a - t * 2.9) + 0.05 * Math.sin(2 * a + t);
}
},
diamond: {
cover: 1.42,
r: function (a) { return 1 / (Math.abs(Math.cos(a)) + Math.abs(Math.sin(a))); }
},
star: {
cover: 2.3,
r: function (a) {
// Five points, outer radius 1, inner radius 0.45, straight edges.
var seg = Math.PI / 5;
var k = ((a % (2 * seg)) + 2 * seg) % (2 * seg);
var inner = 0.45;
var p1 = k < seg ? 1 : inner, p2 = k < seg ? inner : 1;
var b = k < seg ? k : k - seg;
// Radius along a straight edge between two points seg apart.
return (p1 * p2 * Math.sin(seg)) / (p1 * Math.sin(b) + p2 * Math.sin(seg - b));
}
}
};
function origin(el) {
var o = (el.dataset.origin || DEFAULTS.origin).split(/\s+/);
var w = el.offsetWidth, h = el.offsetHeight;
var x = (parseFloat(o[0]) / 100) * w, y = (parseFloat(o[1] || o[0]) / 100) * h;
// Far enough to reach the furthest corner.
var reach = Math.max(Math.hypot(x, y), Math.hypot(w - x, y), Math.hypot(x, h - y), Math.hypot(w - x, h - y));
return { x: x, y: y, reach: reach };
}
function clip(shape, o, p, t, spin) {
if (shape === 'circle' || !SHAPES[shape]) {
return 'circle(' + (p * o.reach).toFixed(1) + 'px at ' + o.x.toFixed(1) + 'px ' + o.y.toFixed(1) + 'px)';
}
var S = SHAPES[shape], R = p * o.reach * S.cover, rot = (spin * p - 90) * Math.PI / 180;
var pts = [], n = shape === 'blob' ? 72 : 80;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2;
var r = R * S.r(a, t);
pts.push((o.x + r * Math.cos(a + rot)).toFixed(1) + 'px ' + (o.y + r * Math.sin(a + rot)).toFixed(1) + 'px');
}
return 'polygon(' + pts.join(',') + ')';
}
function play(el) {
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var spin = +d.spin || DEFAULTS.spin;
var shapes = (d.shape || DEFAULTS.shape).split(/\s+/);
var state = { p: 0 }, turn = 0, start = performance.now();
var o, shape;
function draw() {
var t = (performance.now() - start) / 1000;
if (d.image) {
var size = (state.p * o.reach * 2.6).toFixed(1) + 'px';
el.style.webkitMaskSize = el.style.maskSize = size + ' ' + size;
} else {
el.style.clipPath = clip(shape, o, state.p, t, spin);
}
}
function grow() {
o = origin(el);
shape = shapes[turn++ % shapes.length];
if (d.image) {
var pos = o.x.toFixed(1) + 'px ' + o.y.toFixed(1) + 'px';
el.style.webkitMaskImage = el.style.maskImage = 'url("' + d.image + '")';
el.style.webkitMaskRepeat = el.style.maskRepeat = 'no-repeat';
// Keep the image centred on the origin as it grows.
el.style.webkitMaskPosition = el.style.maskPosition =
'calc(' + pos.split(' ')[0] + ' - var(--smr-half, 0px)) calc(' + pos.split(' ')[1] + ' - var(--smr-half, 0px))';
}
state.p = 0;
draw();
el.style.opacity = '';
var tl = anime.createTimeline({
onComplete: function () {
if (!loop) {
el.style.clipPath = '';
['maskImage', 'webkitMaskImage'].forEach(function (k) { el.style[k] = ''; });
return;
}
setTimeout(grow, 400);
}
});
var upd = function () {
if (d.image) el.style.setProperty('--smr-half', (state.p * o.reach * 1.3).toFixed(1) + 'px');
draw();
};
tl.add(state, { p: [0, 1], duration: duration, ease: 'inOut(2.4)', onUpdate: upd });
if (loop) {
tl.add(state, { p: [1, 0], duration: duration * 0.7, ease: 'in(2.4)', onUpdate: upd },
'+=' + (+d.hold || DEFAULTS.hold));
}
}
setTimeout(grow, +d.delay || DEFAULTS.delay);
}
function init() {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) {
if (!e.isIntersecting) return;
io.unobserve(e.target);
play(e.target);
});
}, { threshold: 0.3 });
document.querySelectorAll('[data-shape-mask-reveal]').forEach(function (el) {
if (el._smr) return;
el._smr = true;
if (reduced()) return;
// Hidden with opacity until it plays: the browser won't report a fully
// clipped element as scrolled into view.
el.style.opacity = 0;
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-shape-mask-reveal>Grow into view</h1>
<!-- a turning star from the bottom-left corner: -->
<h1 data-shape-mask-reveal data-shape="star" data-spin="90" data-origin="0% 100%">Grow into view</h1>
<!-- your own shape: -->
<h1 data-shape-mask-reveal data-image="/heart.svg">Grow into view</h1>