Letter Scatter
Click, tap or sweep the pointer over the text and the letters blast apart from that spot, spinning away before springing back into place. The ones nearest the blast fly furthest.
Scatter me
How to use it
- Paste the snippet just before
</head>. - Add
data-letter-scatterto a heading, link or button label.
Options
Add any of these attributes to the same element.
data-trigger | both (default) scatters on hover and on click or tap. click or hover for just one. |
data-distance | How far the letters fly, in em. Default 1.4. |
data-duration | How long they take to spring back. Default 1200 ms. |
data-autoplay | Scatter by itself every few seconds while on screen, pausing while someone interacts. |
data-interval | Time between autoplay bursts. Default 3200 ms. |
Letters fly outside the element, so make sure nothing around it clips them, or leave some room.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Letter Scatter: a click, tap or passing pointer blasts the letters apart
// from that spot. They spin away, hang for a moment, then spring back into
// place. Letters closest to the blast fly furthest.
// Usage: <h1 data-letter-scatter>Burst apart</h1>
// Optional: data-trigger ("both", the default, scatters on hover and on
// click or tap; "click" or "hover" for just one), data-distance (how far
// the letters fly, in em; default 1.4), data-duration (time to spring back,
// in ms; default 1200), data-autoplay (scatter by itself every few seconds
// while on screen, pausing when someone interacts), data-interval (ms
// between autoplay bursts; default 3200).
var DEFAULTS = { distance: 1.4, duration: 1200, interval: 3200 };
var css =
'.lsc-word{display:inline-block;white-space:nowrap}' +
'.lsc-char{display:inline-block;will-change:transform}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function split(el) {
var text = el.textContent.replace(/\s+/g, ' ').trim();
el.setAttribute('aria-label', text);
el.textContent = '';
var chars = [];
text.split(' ').forEach(function (w, i) {
if (i) el.appendChild(document.createTextNode(' '));
var word = document.createElement('span');
word.className = 'lsc-word';
word.setAttribute('aria-hidden', 'true');
Array.from(w).forEach(function (c) {
var s = document.createElement('span');
s.className = 'lsc-char';
s.textContent = c;
word.appendChild(s);
chars.push({ el: s });
});
el.appendChild(word);
});
return chars;
}
function setup(el) {
var d = el.dataset;
var trigger = d.trigger || 'both';
var distance = d.distance !== undefined ? +d.distance : DEFAULTS.distance;
var duration = +d.duration || DEFAULTS.duration;
var interval = +d.interval || DEFAULTS.interval;
var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
if (getComputedStyle(el).position === 'static') el.style.position = 'relative';
var chars = split(el);
if (reduced()) return;
var fs = 16;
// Resting letter centres relative to the element (offsets ignore transforms).
function measure() {
fs = parseFloat(getComputedStyle(el).fontSize);
chars.forEach(function (c) {
c.cx = c.el.offsetLeft + c.el.offsetWidth / 2;
c.cy = c.el.offsetTop + c.el.offsetHeight / 2;
});
}
measure();
new ResizeObserver(measure).observe(el);
var busyUntil = 0;
// Blast from (px, py), in element coordinates.
function scatter(px, py) {
var em = fs;
busyUntil = performance.now() + 500;
chars.forEach(function (c) {
var dx = c.cx - px, dy = c.cy - py;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 1) { var a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); dist = 1; }
var push = distance * em * (0.45 + 1.1 * Math.exp(-dist / (2.2 * em))) * anime.utils.random(0.75, 1.25, 2);
var tx = dx / dist * push;
var ty = dy / dist * push * 0.8 - em * 0.25 * Math.random();
var spin = anime.utils.random(40, 200) * (Math.random() < 0.5 ? -1 : 1);
var out = anime.utils.random(320, 460);
var hold = anime.utils.random(80, 260);
anime.animate(c.el, {
x: [{ to: tx, duration: out, ease: 'outQuart' }, { to: 0, delay: hold, duration: duration, ease: 'outElastic(1, .6)' }],
y: [{ to: ty, duration: out, ease: 'outQuart' }, { to: 0, delay: hold, duration: duration, ease: 'outElastic(1, .6)' }],
rotate: [{ to: spin, duration: out, ease: 'outQuart' }, { to: 0, delay: hold, duration: duration, ease: 'outElastic(1, .7)' }],
scale: [{ to: anime.utils.random(0.8, 1.2, 2), duration: out, ease: 'outQuart' }, { to: 1, delay: hold, duration: duration * 0.7, ease: 'outBack(2)' }]
});
});
}
function local(e) {
var r = el.getBoundingClientRect();
return { x: e.clientX - r.left - el.clientLeft, y: e.clientY - r.top - el.clientTop };
}
var lastReal = -1e9;
if (trigger !== 'hover') {
el.addEventListener('pointerdown', function (e) {
lastReal = performance.now();
var p = local(e);
scatter(p.x, p.y);
});
}
if (trigger !== 'click') {
el.addEventListener('pointerenter', function (e) {
if (e.pointerType !== 'mouse') return; // touch scatters on tap instead
lastReal = performance.now();
if (performance.now() < busyUntil + duration * 0.6) return;
var p = local(e);
scatter(p.x, p.y);
});
}
if (!autoplay) return;
var timer = null;
function tick() {
if (performance.now() - lastReal > 5000 && chars.length) {
// A blast from somewhere along the line.
var c = chars[Math.floor(Math.random() * chars.length)];
scatter(c.cx + anime.utils.random(-0.3, 0.3, 2) * fs, c.cy + anime.utils.random(-0.2, 0.4, 2) * fs);
}
timer = setTimeout(tick, interval);
}
new IntersectionObserver(function (entries) {
clearTimeout(timer);
if (entries[0].isIntersecting) timer = setTimeout(tick, 700);
}).observe(el);
}
function init() {
document.querySelectorAll('[data-letter-scatter]').forEach(function (el) {
if (el._lsc) return;
el._lsc = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-letter-scatter>Scatter me</h1>
<!-- only on click, flying further: -->
<button type="button" data-letter-scatter data-trigger="click" data-distance="2">Boom</button>