Particle Text
A cloud of particles swarms in along curving paths and settles into the shape of a word. Once formed it shimmers gently and scatters away from the pointer, and it can burst apart and gather again into the next word.
Animacious
Run your pointer through the letters
How to use it
- Paste the snippet just before
</head>. - Wrap a word or short phrase in
<span data-particle-text>. Keep it to one line.
Options
Add any of these attributes to the same element.
data-words | Comma-separated words to cycle through, bursting apart and re-forming each time. |
data-loop | With one word: burst and re-form it over and over. |
data-hold | How long each word stays. Default 2600 ms. |
data-colors | Comma-separated colours, spread from left to right. Default the text colour. |
data-gap | Space between particles in px. Smaller means more particles. Default about a 24th of the font size. |
The particles take their shape from the element’s own font, size and alignment. The real text stays in the page for screen readers and search engines, and with reduced motion it’s simply shown as text.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Particle Text: a cloud of particles swarms together to form a word, then
// shimmers in place and scatters away from the pointer. With data-loop (or
// a list of words) it bursts apart and gathers again into the next word.
// Usage: <h1><span data-particle-text>Hello</span></h1>
// Optional: data-words (comma-separated words to cycle through; the
// element's own text is what screen readers read), data-loop (burst and
// re-form), data-hold (ms each word stays, default 2600), data-colors
// (comma-separated, spread from left to right; default the text colour),
// data-gap (px between particles, default about a 24th of the font size).
// Best on one line of text. Starts when scrolled into view.
var DEFAULTS = { hold: 2600 };
var css =
'[data-particle-text]{position:relative;display:inline-block}' +
'.ptx-on{color:transparent!important;-webkit-text-fill-color:transparent}' +
'.ptx-canvas{position:absolute;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function build(el) {
var d = el.dataset;
var cs = getComputedStyle(el);
var text = el.textContent.trim();
var words = d.words ? d.words.split(',').map(function (s) { return s.trim(); }).filter(Boolean) : [text];
var loop = d.loop !== undefined && d.loop !== 'false';
var cycle = loop || words.length > 1;
var hold = +d.hold || DEFAULTS.hold;
var colors = d.colors ? d.colors.split(',').map(function (c) { return c.trim(); }) : [cs.color];
var R = anime.utils.random;
var canvas = document.createElement('canvas');
canvas.className = 'ptx-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
var w = 0, h = 0, cw = 0, ch = 0, pad = 0, dpr = 1, gap = 3, dot = 1;
var parts = [], targets = [], word = 0;
var phase = 'wait', clock = 0, alpha = 0;
var pointer = null;
// Where each word's pixels are, sampled from an offscreen canvas.
function sample() {
var size = parseFloat(cs.fontSize);
gap = +d.gap || Math.max(2, Math.round(size / 24));
dot = Math.max(1, gap * 0.4);
w = el.offsetWidth;
h = el.offsetHeight;
pad = Math.round(Math.max(24, size * 0.7));
cw = w + pad * 2;
ch = h + pad * 2;
dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.style.left = canvas.style.top = -pad + 'px';
canvas.style.width = cw + 'px';
canvas.style.height = ch + 'px';
canvas.width = cw * dpr;
canvas.height = ch * dpr;
var off = document.createElement('canvas');
off.width = cw;
off.height = ch;
var o = off.getContext('2d', { willReadFrequently: true });
var font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + size + 'px ' + cs.fontFamily;
var align = cs.textAlign === 'center' ? 'center' : cs.textAlign === 'right' || cs.textAlign === 'end' ? 'right' : 'left';
return words.map(function (wd) {
o.clearRect(0, 0, cw, ch);
o.font = font;
if ('letterSpacing' in o) o.letterSpacing = cs.letterSpacing === 'normal' ? '0px' : cs.letterSpacing;
// Shrink a word that's wider than the element.
var m = o.measureText(wd);
var fit = Math.min(1, w / Math.max(1, m.width));
if (fit < 1) { o.font = cs.fontStyle + ' ' + cs.fontWeight + ' ' + size * fit + 'px ' + cs.fontFamily; m = o.measureText(wd); }
var asc = m.fontBoundingBoxAscent || size * 0.8, desc = m.fontBoundingBoxDescent || size * 0.2;
o.textAlign = align;
o.textBaseline = 'alphabetic';
o.fillStyle = '#000';
var x = align === 'center' ? pad + w / 2 : align === 'right' ? pad + w : pad;
o.fillText(wd, x, pad + (h - asc - desc) / 2 + asc);
var px = o.getImageData(0, 0, cw, ch).data, pts = [];
for (var y = gap / 2; y < ch; y += gap) {
for (var xx = gap / 2; xx < cw; xx += gap) {
if (px[((y | 0) * cw + (xx | 0)) * 4 + 3] > 128) pts.push(xx, y);
}
}
return pts;
});
}
// Every particle gets a spot in each word; spare ones double up.
function assign() {
var n = 0;
targets = sample();
targets.forEach(function (t) { n = Math.max(n, t.length / 2); });
targets = targets.map(function (t) {
var m = t.length / 2, order = [], out = new Float32Array(n * 2);
for (var i = 0; i < m; i++) order.push(i);
anime.utils.shuffle(order);
for (i = 0; i < n; i++) {
var k = i < m ? order[i] : order[(Math.random() * m) | 0];
out[i * 2] = t[k * 2];
out[i * 2 + 1] = t[k * 2 + 1];
}
return out;
});
while (parts.length < n) parts.push({ x: R(0, cw), y: R(0, ch), vx: 0, vy: 0, s: R(0.7, 1.15, 2) });
parts.length = n;
}
// Gather: each particle flies a curved path to its spot, at its own pace.
function gather() {
var t = targets[word];
parts.forEach(function (p, i) {
p.sx = p.x; p.sy = p.y;
p.tx = t[i * 2]; p.ty = t[i * 2 + 1];
var mx = (p.sx + p.tx) / 2, my = (p.sy + p.ty) / 2;
var dx = p.tx - p.sx, dy = p.ty - p.sy;
var bend = R(-0.6, 0.6, 2);
p.cx = mx - dy * bend;
p.cy = my + dx * bend;
p.delay = R(0, 650);
p.dur = R(900, 1500);
p.vx = p.vy = 0;
});
phase = 'gather';
clock = 0;
}
function burst() {
var cx = cw / 2, cy = ch / 2;
parts.forEach(function (p) {
var a = Math.atan2(p.y - cy, p.x - cx) + R(-0.6, 0.6, 2);
var s = R(0.15, 0.6, 2);
p.vx = Math.cos(a) * s;
p.vy = Math.sin(a) * s - 0.08;
});
phase = 'burst';
clock = 0;
}
function ease(t) { return 1 - Math.pow(1 - t, 3); }
function step(dt) {
clock += dt;
if (phase === 'gather') {
alpha = Math.min(1, alpha + dt / 400);
var done = true;
parts.forEach(function (p) {
var k = Math.max(0, Math.min(1, (clock - p.delay) / p.dur));
if (k < 1) done = false;
var e = ease(k), u = 1 - e;
p.x = u * u * p.sx + 2 * u * e * p.cx + e * e * p.tx;
p.y = u * u * p.sy + 2 * u * e * p.cy + e * e * p.ty;
});
if (done) { phase = 'hold'; clock = 0; }
} else if (phase === 'hold') {
// Spring back to the spot, shimmer a little, dodge the pointer.
var f = dt / 16;
parts.forEach(function (p, i) {
var tx = p.tx + Math.sin(clock * 0.004 + i) * 0.5;
var ty = p.ty + Math.cos(clock * 0.003 + i * 1.3) * 0.5;
p.vx += (tx - p.x) * 0.06 * f;
p.vy += (ty - p.y) * 0.06 * f;
if (pointer) {
var dx = p.x - pointer.x, dy = p.y - pointer.y, d2 = dx * dx + dy * dy, r = pointer.r;
if (d2 < r * r && d2 > 0.01) {
var push = (1 - Math.sqrt(d2) / r) * 2.4 * f;
var dd = Math.sqrt(d2);
p.vx += (dx / dd) * push;
p.vy += (dy / dd) * push;
}
}
p.vx *= Math.pow(0.82, f);
p.vy *= Math.pow(0.82, f);
p.x += p.vx * f;
p.y += p.vy * f;
});
if (cycle && clock > hold) burst();
} else if (phase === 'burst') {
var drag = Math.pow(0.97, dt / 16);
parts.forEach(function (p) {
p.vx *= drag; p.vy *= drag;
p.x += p.vx * dt;
p.y += p.vy * dt;
});
if (clock > 650) {
word = (word + 1) % words.length;
gather();
}
}
}
function draw() {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cw, ch);
if (colors.length > 1) {
var g = ctx.createLinearGradient(pad, 0, pad + w, 0);
colors.forEach(function (c, i) { g.addColorStop(i / (colors.length - 1), c); });
ctx.fillStyle = g;
} else ctx.fillStyle = colors[0];
ctx.globalAlpha = alpha;
ctx.beginPath();
for (var i = 0; i < parts.length; i++) {
var p = parts[i], r = dot * p.s;
ctx.moveTo(p.x + r, p.y);
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
}
ctx.fill();
}
var last = 0;
var timer = anime.createTimer({
loop: true,
duration: 1000,
autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
step(dt);
draw();
}
});
function relayout() {
var was = phase;
assign();
if (was === 'wait') return;
// Keep going from where the particles are, towards the new spots.
var t = targets[word];
parts.forEach(function (p, i) { p.tx = t[i * 2]; p.ty = t[i * 2 + 1]; });
if (was === 'gather') gather();
}
el.classList.add('ptx-on');
assign();
// Particles start scattered round the word, invisible until it's in view.
parts.forEach(function (p) {
var a = Math.random() * Math.PI * 2, r = Math.max(cw, ch) * R(0.3, 0.6, 2);
p.x = cw / 2 + Math.cos(a) * r;
p.y = ch / 2 + Math.sin(a) * r * 0.6;
});
if (window.ResizeObserver) {
var first = true;
new ResizeObserver(function () {
if (first) { first = false; return; }
relayout();
if (phase === 'wait') draw();
}).observe(el);
}
el.addEventListener('pointermove', function (e) {
var r = canvas.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top, r: Math.max(30, parseFloat(cs.fontSize) * 0.9) };
});
el.addEventListener('pointerleave', function () { pointer = null; });
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) {
if (phase === 'wait') gather();
last = 0;
timer.play();
} else timer.pause();
}, { threshold: 0.3 }).observe(el);
}
function init() {
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var els = document.querySelectorAll('[data-particle-text]');
// Wait for web fonts, so the particles trace the real letters.
(document.fonts ? document.fonts.ready : Promise.resolve()).then(function () {
els.forEach(function (el) {
if (el._ptx) return;
el._ptx = true;
build(el);
});
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1><span data-particle-text>Hello</span></h1>
<!-- cycling words, in a gradient: -->
<h1><span data-particle-text data-words="Design,Build,Ship" data-colors="#a855f7,#ec4899">Design</span></h1>