Letter Assemble
Letters start scattered all over the area, tumbled and faint, and drift together into the word. Loop it and they wander off to new places before coming back.
Come together
How to use it
- Paste the snippet just before
</head>. - Add
data-letter-assembleto a heading or short line of text.
Options
Add any of these attributes to the same element.
data-loop | Assemble, hold, drift apart and assemble again. |
data-hold | How long the word stays together, when looping. Default 1800 ms. |
data-spin | The most a scattered letter is turned, in degrees. Default 120. |
data-duration | How long the drift takes. Default 1800 ms. |
data-stagger | The most a letter may set off late, picked at random. Default 500 ms. |
data-delay | Wait before starting. Default 0 ms. |
Letters scatter across the element’s parent, so they stay inside the section the text sits in. Best on a few words.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Letter Assemble: letters start scattered around the area, tumbled and
// faint, and drift together into the word.
// Usage: <h1 data-letter-assemble>Come together</h1>
// Optional: data-loop (letters drift apart to new places and assemble
// again), data-hold (ms to stay assembled), data-spin (most degrees a
// scattered letter is turned; default 120), data-duration (ms for the
// drift), data-stagger (most ms a letter may start late), data-delay (ms
// before starting).
// The letters scatter across the element's parent (the section it sits
// in), so they never fly off somewhere you can't see.
var DEFAULTS = { spin: 120, duration: 1800, stagger: 500, delay: 0, hold: 1800 };
var css =
'.las-word{display:inline-block;white-space:nowrap}' +
'.las-char{display:inline-block;will-change:transform,opacity}';
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 (word, i) {
if (i) el.appendChild(document.createTextNode(' '));
var w = document.createElement('span');
w.className = 'las-word';
w.setAttribute('aria-hidden', 'true');
Array.from(word).forEach(function (c) {
var s = document.createElement('span');
s.className = 'las-char';
s.textContent = c;
w.appendChild(s);
chars.push(s);
});
el.appendChild(w);
});
return chars;
}
// Where each letter's centre sits when assembled, relative to the element.
// Turning and scaling happen around the centre, so the centre is only
// moved by the offset the letter currently has (st.cur).
function measure(el, st) {
var box = el.getBoundingClientRect();
st.rest = st.chars.map(function (c, i) {
var r = c.getBoundingClientRect();
var off = st.cur ? st.cur[i] : { x: 0, y: 0 };
return {
x: r.left - box.left + r.width / 2 - off.x,
y: r.top - box.top + r.height / 2 - off.y,
w: c.offsetWidth,
h: c.offsetHeight
};
});
}
// A random spot inside the parent for every letter, as an offset from
// where that letter belongs.
function scatter(el, st) {
var spin = +el.dataset.spin || DEFAULTS.spin;
var area = (el.parentElement || el).getBoundingClientRect();
var box = el.getBoundingClientRect();
return st.rest.map(function (p) {
var mx = Math.min(p.w, area.width / 4);
var my = Math.min(p.h * 0.6, area.height / 4);
var tx = anime.utils.random(area.left + mx, area.right - mx) - box.left;
var ty = anime.utils.random(area.top + my, area.bottom - my) - box.top;
return {
x: tx - p.x,
y: ty - p.y,
rotate: anime.utils.random(-spin, spin),
scale: anime.utils.random(0.5, 1.3, 2)
};
});
}
function apply(st, spots) {
st.cur = spots;
st.chars.forEach(function (c, i) {
var s = spots[i];
anime.utils.set(c, { x: s.x, y: s.y, rotate: s.rotate, scale: s.scale, opacity: 0 });
});
}
function play(el) {
var st = el._las;
var d = el.dataset;
var loop = d.loop !== undefined && d.loop !== 'false';
var duration = +d.duration || DEFAULTS.duration;
var stagger = d.stagger !== undefined ? +d.stagger : DEFAULTS.stagger;
var hold = +d.hold || DEFAULTS.hold;
var lag = st.chars.map(function () { return Math.random() * stagger; });
// Work out where they will drift off to now: values are read when the
// timeline is built.
measure(el, st);
var out = scatter(el, st);
var tl = anime.createTimeline({
delay: st.played ? 0 : +d.delay || DEFAULTS.delay,
onComplete: function () { if (loop) play(el); }
});
st.played = true;
tl.add(st.chars, {
x: 0,
y: 0,
rotate: 0,
scale: 1,
opacity: { to: 1, duration: duration * 0.35, ease: 'linear' },
duration: duration,
ease: 'inOut(3)',
delay: function (t, i) { return lag[i]; }
});
if (loop) {
// Drift apart to brand new places, then jump (invisibly) to a fresh
// scatter for the next assembly.
tl.add(st.chars, {
x: function (t, i) { return out[i].x; },
y: function (t, i) { return out[i].y; },
rotate: function (t, i) { return out[i].rotate; },
scale: function (t, i) { return out[i].scale; },
opacity: 0,
duration: duration * 0.7,
ease: 'in(2)',
delay: function (t, i) { return lag[i] * 0.5; }
}, '+=' + hold);
tl.call(function () {
st.cur = out;
measure(el, st);
apply(st, scatter(el, st));
});
}
}
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.2 });
document.querySelectorAll('[data-letter-assemble]').forEach(function (el) {
if (el._las) return;
var st = (el._las = { chars: split(el) });
if (reduced()) return;
measure(el, st);
apply(st, scatter(el, st));
io.observe(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><h1 data-letter-assemble>Come together</h1>
<!-- or, looping: -->
<h1 data-letter-assemble data-loop>Come together</h1>