Assemble
React + Tailwind
import { useMemo } from 'react';
const css = `
/* Scattered → flies into its slot (0–30%) → the block turns (30–60%) → bursts back out. */
@keyframes loader-assemble-p {
0% { transform: translate(var(--sx), var(--sy)) rotate(var(--r)) scale(0.5); opacity: 0; }
8% { opacity: 1; }
30%, 64% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
86% { transform: translate(var(--ex), var(--ey)) rotate(var(--r)) scale(0.4); opacity: 0; }
100% { transform: translate(var(--ex), var(--ey)) rotate(var(--r)) scale(0.4); opacity: 0; }
}
@keyframes loader-assemble-turn {
0%, 32% { transform: rotate(0deg); }
60%, 100% { transform: rotate(90deg); }
}
.loader-assemble { display: grid; place-items: center; width: 110px; height: 110px; }
.loader-assemble__block {
display: grid;
grid-template-columns: repeat(4, 9px);
grid-auto-rows: 9px;
gap: 2px;
animation: loader-assemble-turn 2.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.loader-assemble__p {
border-radius: 2px;
background: light-dark(#42454c, #e5e6e9);
opacity: 0;
animation: loader-assemble-p 2.8s cubic-bezier(0.22, 1, 0.36, 1) var(--d) infinite;
}
.loader-assemble__p:nth-child(3n) { background: light-dark(#5f626a, #a7abb2); }
.loader-assemble__p:nth-child(5n) { background: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.loader-assemble__block { animation: none; }
.loader-assemble__p { animation: none; opacity: 1; }
}
`;
export function AssembleLoader() {
// Seeded scatter in (where it comes from) and out (where it bursts to), per particle.
const parts = useMemo(() => {
let seed = 21;
const rand = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
return Array.from({ length: 16 }, (_, i) => {
const a = rand() * Math.PI * 2;
const b = rand() * Math.PI * 2;
const r1 = 36 + rand() * 18;
const r2 = 34 + rand() * 20;
return {
'--sx': `${(Math.cos(a) * r1).toFixed(1)}px`,
'--sy': `${(Math.sin(a) * r1).toFixed(1)}px`,
'--ex': `${(Math.cos(b) * r2).toFixed(1)}px`,
'--ey': `${(Math.sin(b) * r2).toFixed(1)}px`,
'--r': `${Math.round(rand() * 360 - 180)}deg`,
'--d': `${((i % 4) * 0.025 + Math.floor(i / 4) * 0.025).toFixed(3)}s`,
} as React.CSSProperties;
});
}, []);
return (
<>
<style>
{css}
</style>
<div className="loader-assemble" role="status" aria-label="Loading">
<div className="loader-assemble__block" aria-hidden="true">
{parts.map((s, i) => (
<span key={i} className="loader-assemble__p" style={s} />
))}
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet. No JavaScript.
HTML
<div class="loader-assemble" role="status" aria-label="Loading">
<div class="loader-assemble__block" aria-hidden="true">
<span class="loader-assemble__p" style="--sx:51.6px;--sy:0.1px;--ex:3.6px;--ey:-46.5px;--r:-112deg;--d:0.000s"></span>
<span class="loader-assemble__p" style="--sx:-33.1px;--sy:-23.5px;--ex:39.2px;--ey:-3.0px;--r:46deg;--d:0.025s"></span>
<span class="loader-assemble__p" style="--sx:41.6px;--sy:14.6px;--ex:40.6px;--ey:-26.6px;--r:-136deg;--d:0.050s"></span>
<span class="loader-assemble__p" style="--sx:27.7px;--sy:27.3px;--ex:29.1px;--ey:19.5px;--r:-35deg;--d:0.075s"></span>
<span class="loader-assemble__p" style="--sx:4.6px;--sy:-42.4px;--ex:-39.5px;--ey:21.0px;--r:97deg;--d:0.025s"></span>
<span class="loader-assemble__p" style="--sx:45.1px;--sy:19.7px;--ex:44.2px;--ey:-20.4px;--r:81deg;--d:0.050s"></span>
<span class="loader-assemble__p" style="--sx:33.6px;--sy:30.3px;--ex:53.9px;--ey:2.1px;--r:-15deg;--d:0.075s"></span>
<span class="loader-assemble__p" style="--sx:40.6px;--sy:-32.4px;--ex:-11.1px;--ey:49.0px;--r:62deg;--d:0.100s"></span>
<span class="loader-assemble__p" style="--sx:14.9px;--sy:36.2px;--ex:-33.7px;--ey:-37.5px;--r:65deg;--d:0.050s"></span>
<span class="loader-assemble__p" style="--sx:-45.8px;--sy:-7.7px;--ex:-4.4px;--ey:48.3px;--r:-121deg;--d:0.075s"></span>
<span class="loader-assemble__p" style="--sx:44.3px;--sy:30.7px;--ex:48.4px;--ey:10.0px;--r:16deg;--d:0.100s"></span>
<span class="loader-assemble__p" style="--sx:-51.3px;--sy:1.1px;--ex:7.4px;--ey:35.1px;--r:-82deg;--d:0.125s"></span>
<span class="loader-assemble__p" style="--sx:51.2px;--sy:-0.7px;--ex:-39.7px;--ey:-27.0px;--r:79deg;--d:0.075s"></span>
<span class="loader-assemble__p" style="--sx:-36.6px;--sy:37.3px;--ex:-44.1px;--ey:-27.6px;--r:18deg;--d:0.100s"></span>
<span class="loader-assemble__p" style="--sx:-50.5px;--sy:-4.7px;--ex:34.5px;--ey:15.4px;--r:-135deg;--d:0.125s"></span>
<span class="loader-assemble__p" style="--sx:0.8px;--sy:-36.9px;--ex:3.6px;--ey:-53.7px;--r:-117deg;--d:0.150s"></span>
</div>
</div>CSS
/* Scattered → flies into its slot (0–30%) → the block turns (30–60%) → bursts back out. */
@keyframes loader-assemble-p {
0% { transform: translate(var(--sx), var(--sy)) rotate(var(--r)) scale(0.5); opacity: 0; }
8% { opacity: 1; }
30%, 64% { transform: translate(0, 0) rotate(0deg) scale(1); opacity: 1; }
86% { transform: translate(var(--ex), var(--ey)) rotate(var(--r)) scale(0.4); opacity: 0; }
100% { transform: translate(var(--ex), var(--ey)) rotate(var(--r)) scale(0.4); opacity: 0; }
}
@keyframes loader-assemble-turn {
0%, 32% { transform: rotate(0deg); }
60%, 100% { transform: rotate(90deg); }
}
.loader-assemble { display: grid; place-items: center; width: 110px; height: 110px; }
.loader-assemble__block {
display: grid;
grid-template-columns: repeat(4, 9px);
grid-auto-rows: 9px;
gap: 2px;
animation: loader-assemble-turn 2.8s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.loader-assemble__p {
border-radius: 2px;
background: light-dark(#42454c, #e5e6e9);
opacity: 0;
animation: loader-assemble-p 2.8s cubic-bezier(0.22, 1, 0.36, 1) var(--d) infinite;
}
.loader-assemble__p:nth-child(3n) { background: light-dark(#5f626a, #a7abb2); }
.loader-assemble__p:nth-child(5n) { background: light-dark(#373a40, #f1f2f3); }
@media (prefers-reduced-motion: reduce) {
.loader-assemble__block { animation: none; }
.loader-assemble__p { animation: none; opacity: 1; }
}Goal: Create a "Assemble" loader.
Visual behavior: Sixteen scattered particles fly in from all sides and lock together into a block, the block turns a quarter, then bursts apart in new directions and gathers again.
Technique:
- A 4 x 4 grid of squares; each square has seeded start and end offsets, a spin and a small delay in CSS variables, so the scatter is the same every render.
- One keyframe takes each square from its scattered start into its grid slot (0 to 30%), holds, then flings it out to its end offset while shrinking and fading.
- The grid itself turns 90deg while the particles hold together, on the same 2.8s timeline.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the block shows assembled and still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Assemble" loader.
Visual behavior: Sixteen scattered particles fly in from all sides and lock together into a block, the block turns a quarter, then bursts apart in new directions and gathers again.
Technique:
- A 4 x 4 grid of squares; each square has seeded start and end offsets, a spin and a small delay in CSS variables, so the scatter is the same every render.
- One keyframe takes each square from its scattered start into its grid slot (0 to 30%), holds, then flings it out to its end offset while shrinking and fading.
- The grid itself turns 90deg while the particles hold together, on the same 2.8s timeline.
Accessibility: The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the block shows assembled and still.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
Sixteen scattered particles fly in from all sides and lock together into a block, the block turns a quarter, then bursts apart in new directions and gathers again.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- continuous, loop, morph, delay stagger
- Techniques
- keyframes, css variables, stagger
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
The root has role="status" and aria-label="Loading"; everything inside is decorative. Under prefers-reduced-motion the block shows assembled and still.