Text Scatter Assemble Animation — Letters Converge JS
Text Scatter Assemble · Animations · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="scene">
<h1 class="assemble-heading" id="heading">Pieces find their place</h1>
<button class="replay" onclick="assemble()">Scatter and reassemble</button>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #fff7ed; display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 20px; overflow: hidden; }
.scene { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 30px; max-width: 760px; }
.assemble-heading {
font-size: clamp(28px, 6.5vw, 50px);
font-weight: 800;
color: #7c2d12;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.letter {
display: inline-block;
position: relative;
opacity: 0;
transition: transform 0.85s cubic-bezier(0.16, 1, 0.3, 1), opacity 0.5s ease;
}
.letter.space { width: 0.32em; }
.letter.placed { opacity: 1; transform: translate(0, 0) rotate(0deg) scale(1); }
.replay {
padding: 9px 20px; font-size: 13px; font-weight: 600;
background: #c2410c; color: #fff; border: none; border-radius: 999px;
cursor: pointer; transition: background 0.15s, transform 0.1s;
}
.replay:hover { background: #9a3412; }
.replay:active { transform: scale(0.96); }function buildLetters(el) {
const text = el.textContent.trim();
el.textContent = '';
const frag = document.createDocumentFragment();
text.split('').forEach((ch) => {
const span = document.createElement('span');
span.className = 'letter' + (ch === ' ' ? ' space' : '');
span.textContent = ch === ' ' ? '\u00a0' : ch;
frag.appendChild(span);
});
el.appendChild(frag);
return el.querySelectorAll('.letter');
}
function scatter(letters) {
letters.forEach((l) => {
l.classList.remove('placed');
const dx = (Math.random() - 0.5) * 700;
const dy = (Math.random() - 0.5) * 500;
const rot = (Math.random() - 0.5) * 540;
const scale = 0.3 + Math.random() * 0.6;
l.style.transition = 'none';
l.style.opacity = '0';
l.style.transform = 'translate(' + dx + 'px, ' + dy + 'px) rotate(' + rot + 'deg) scale(' + scale + ')';
});
}
function assemble() {
const heading = document.getElementById('heading');
const letters = document.querySelectorAll('.letter');
scatter(letters);
void heading.offsetWidth;
letters.forEach((l, i) => {
l.style.transition = '';
l.style.opacity = '';
l.style.transform = '';
setTimeout(() => {
l.classList.add('placed');
}, i * 35);
});
}
buildLetters(document.getElementById('heading'));
assemble();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Text Scatter Assemble — Random Offsets, Rotation & Staggered Convergence

A text scatter assemble starts every letter of a headline flung to a random position around the screen — offset, rotated, scaled down, and invisible — then converges each one back to its correct spot in the sentence with a staggered, eased transition, so the whole headline looks like it is physically reassembling itself from scattered pieces. Unlike particle-based text formation effects that render text as thousands of tiny dots on a <canvas>, this uses the real DOM letters themselves — each character is still a genuine, selectable, accessible piece of text throughout the animation, just transformed in space.
Splitting into letters
buildLetters(el) wraps each character of the heading in its own <span class="letter">, converting literal spaces into non-breaking-space spans so word gaps hold their width once the spans become display: inline-block. This is the same splitting approach used by 3D character flip reveal and letter gravity drop, each applying a different physical motion to the resulting per-letter spans.
Scattering to random positions
scatter(letters) loops over every letter and computes three independent random values: dx/dy — a random horizontal and vertical offset up to ±350px and ±250px respectively ((Math.random() - 0.5) * 700 centers the random range on zero) — and rot, a random rotation up to ±270 degrees, plus a random scale between 0.3 and 0.9. Setting l.style.transition = 'none' first means this scattered starting state is applied instantly, with no visible animation, before the reassembly begins.
Converging back with a staggered ease-out
After scattering, assemble() forces a layout flush with void heading.offsetWidth (necessary so the browser registers the transition: none scattered state before a transition is re-enabled — the same forced-reflow technique used in word spring bounce heading), clears each letter's inline transition back to the CSS-declared 0.85s cubic-bezier(0.16, 1, 0.3, 1), and then, staggered by 35ms per letter index via setTimeout, adds a .placed class to each one. .placed resets opacity, transform to translate(0, 0) rotate(0deg) scale(1) — the letter's true position in the sentence — and the long, decelerating cubic-bezier(0.16, 1, 0.3, 1) curve (a common "ease-out-expo"-style curve) makes each letter's flight feel like it is gliding to a gentle stop rather than snapping into place.
Why random values are generated fresh each call
Because scatter() calls Math.random() independently for every letter on every invocation, no two runs of the "Scatter and reassemble" button produce the same scattered starting layout — every replay looks like a genuinely different, unpredictable dispersal that then converges to the identical final sentence.
Step by step
How to Use
- 1Watch it load, then replayLetters scatter and fly into place on page load. Click "Scatter and reassemble" to watch a fresh, differently-randomized scatter converge again.
- 2Change the headline textEdit the text inside #heading in the HTML panel — buildLetters() re-splits it into per-letter spans on load.
- 3Adjust the scatter radiusIn the JS panel, change the 700 and 500 multipliers on dx and dy inside scatter() for a wider or tighter starting dispersal.
- 4Adjust the rotation rangeChange the 540 multiplier on rot inside scatter() — larger values produce more dramatic tumbling before letters settle.
- 5Change the convergence speedIn the CSS panel, update 0.85s on the .letter transition, or the 35 multiplier in the setTimeout stagger inside assemble() in the JS panel.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component, or "Tailwind" for a React + Tailwind version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
No — every letter remains a real HTML span throughout the animation, just transformed with CSS translate, rotate, and scale. This keeps the text genuinely selectable and accessible, unlike canvas-based particle text effects that render pixels rather than characters.
Scattering is meant to be the instantaneous starting state, not an animated motion in itself — only the convergence back to the correct position should be visibly animated. Setting transition: none before applying the random scattered transform makes that jump invisible, so the only motion the viewer sees is letters flying into place.
Removing a style property (or setting transition: none) and then immediately changing it back in the same synchronous JS tick does not reliably force the browser to register the intermediate state. Reading a layout property like offsetWidth forces a synchronous style recalculation in between, guaranteeing the later transition change is actually observed and animates correctly.
scatter() calls Math.random() fresh for every letter's dx, dy, rotation, and scale on every single call to assemble(). Because these values are never cached or seeded, no two scatter states are ever identical, even though the final assembled sentence is always the same.
Change the 0.85s duration in the .letter transition rule in the CSS panel for the overall speed of each individual letter's flight, and change the 35ms multiplier in the setTimeout stagger inside assemble() in the JS panel for how quickly letters begin converging one after another.
Yes. Click "JSX" for a React component. Store each letter's scattered transform values in component state generated with Math.random() on trigger, apply them as inline styles, and toggle a "placed" boolean per letter (or all at once with staggered setTimeout calls) to animate the transition back to the identity transform.