Live preview needs JavaScript. The code below works without any dependencies.
Prompt for your AI assistant
Paste this into Claude, Cursor or ChatGPT. The settings update as you move the controls above.
Build "Galaxy spiral particle field" for my site using the Enrichr Library element at https://enrichr.dev/elements/galaxy-spiral.html.
Read the machine-readable spec and source at https://enrichr.dev/elements/galaxy-spiral.json (markup, CSS, JavaScript, parameters).
Requirements: vanilla JavaScript and CSS, no new dependencies; keep the behaviour of the source unchanged; respect prefers-reduced-motion; work on touch screens; keep it accessible.
Settings:
- accent: #e6ff55
- count: 5000
- speed: 0.5
- tilt: 0.6
- arms: 3
Verify: reduced motion shows no movement, it works at 375px wide, there are no console errors, and scrolling stays smooth.The same spec as JSON, for tools that read it directly: /elements/galaxy-spiral.json.
Code
Mount it on an element that contains the markup. It returns an object with update(options) and destroy().
HTML
<div class="ef-gsp">
<div class="ef-gsp__wrap">
<canvas class="ef-gsp__cv" aria-hidden="true"></canvas>
<svg class="ef-gsp__piece" viewBox="1513.7 1453.9 322.3 322.3" aria-hidden="true" focusable="false"><path fill="currentColor" d="M1821 1761.2h-292.3v-111.6c0-8 6.5-14.5 14.5-14.5 4.2 0 8.2 1.9 11 5.1 7.8 8.6 19.7 13.6 32.7 11.9 16.8-2.3 30.1-16 32-32.7 2.5-22.6-15.1-41.6-37.1-41.6-11 0-20.9 4.7-27.7 12.3-2.8 3.1-6.7 4.9-10.9 4.9-8 0-14.5-6.4-14.5-14.4v-111.7h292.3v111.7c0 8-6.4 14.4-14.5 14.4-4.2 0-8.1-1.8-10.9-5-7.9-8.6-19.7-13.6-32.7-11.9-16.8 2.2-30.1 15.9-32 32.7-2.5 22.6 15.1 41.6 37.1 41.6 11 0 20.8-4.8 27.7-12.3 2.8-3.1 6.7-5 10.8-5 8.1 0 14.5 6.5 14.5 14.5z"/></svg>
</div>
</div>CSS
/* Enrichr Library · https://enrichr.dev */
.ef-gsp { width: 100%; height: 100%; }
.ef-gsp__wrap { position: relative; box-sizing: border-box; width: 100%; min-height: 20rem; height: 100%; border-radius: 1rem; overflow: hidden; background: #0c1e18; font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
.ef-gsp__cv { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.ef-gsp__piece { position: absolute; right: 1rem; bottom: 1rem; width: 2.2rem; height: 2.2rem; color: #e6ff55; opacity: 0.85; pointer-events: none; transition: transform 300ms cubic-bezier(.2,.8,.2,1); transform: translate(var(--px, 0px), var(--py, 0px)) rotate(var(--pr, 0deg)); }
@media (prefers-reduced-motion: reduce) { .ef-gsp__piece { transition: none; } }JavaScript
// Enrichr Library · https://enrichr.dev
// Galaxy spiral: thousands of tiny particles on spiral arms, slowly rotating in canvas 2D (an original build, not a port).
// Each particle is 3D-projected with perspective; brightness and size follow depth, and far-to-near draw order uses depth buckets.
// The pointer tilts the whole disc (parallax). Reduced motion draws one still frame. Pauses off-screen.
// mount(root, { background, accent, count (1000-9000), speed (0-2), tilt (0-1), arms (2-5) })
function mount(root, o) {
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var wrap = root.querySelector(".ef-gsp__wrap");
var cv = root.querySelector(".ef-gsp__cv");
var piece = root.querySelector(".ef-gsp__piece");
var ctx = cv.getContext("2d");
var opts = {}, P = null, W = 0, H = 0, dpr = 1, X = null, Y = null;
var rot = 0, tx = 0, ty = 0, ttx = 0, tty = 0, raf = 0, last = 0, visible = true, dead = false;
var NB = 8, NC = 4;
var COLS = ["#fbf8f1", "#e6ff55", "#c6f26b", "#2f7d5b"];
var buckets = [];
for (var b = 0; b < NB * NC; b++) buckets.push([]);
function rnd(seed) { var s = seed; return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; }; }
function build() {
var n = opts.count, r = rnd(7);
P = { n: n, x: new Float32Array(n), y: new Float32Array(n), z: new Float32Array(n), c: new Uint8Array(n), s: new Float32Array(n) };
X = new Float32Array(n); Y = new Float32Array(n);
for (var i = 0; i < n; i++) {
var rad = Math.pow(r(), 1.5) * 0.95 + 0.02;
var arm = Math.floor(r() * opts.arms);
var ang = arm * Math.PI * 2 / opts.arms + rad * 5.2;
var spread = (r() + r() + r() - 1.5) * 0.45;
ang += spread * (rad + 0.25);
var core = rad < 0.14;
var q = core ? r() * Math.PI * 2 : ang;
var rr = core ? rad * (0.5 + r()) : rad + (r() - 0.5) * 0.04;
P.x[i] = Math.cos(q) * rr;
P.y[i] = Math.sin(q) * rr;
P.z[i] = (r() + r() - 1) * (core ? 0.15 : 0.04) * (1.2 - rad);
var cr = r();
P.c[i] = rad < 0.22 ? (cr < 0.6 ? 0 : 1) : rad < 0.55 ? (cr < 0.25 ? 0 : cr < 0.6 ? 1 : 2) : (cr < 0.12 ? 1 : cr < 0.45 ? 2 : 3);
P.s[i] = 0.5 + r() * r() * 1.6;
}
}
function size() {
var rc = wrap.getBoundingClientRect();
W = Math.max(40, rc.width); H = Math.max(40, rc.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
draw();
}
function draw() {
if (!P) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.globalAlpha = 1;
ctx.fillStyle = opts.background;
ctx.fillRect(0, 0, W, H);
var cx = W / 2, cy = H / 2, R = Math.min(W, H) * 0.46;
var g = ctx.createRadialGradient(cx, cy, 0, cx, cy, R * 0.55);
g.addColorStop(0, "rgba(230,255,85,0.26)"); g.addColorStop(0.4, "rgba(47,125,91,0.18)"); g.addColorStop(1, "rgba(12,30,24,0)");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
var tiltX = 1.0 + ty * 0.7 * opts.tilt;
var cs = Math.cos(rot), sn = Math.sin(rot), ct = Math.cos(tiltX), st = Math.sin(tiltX);
var yaw = tx * 0.5 * opts.tilt, cyaw = Math.cos(yaw), syaw = Math.sin(yaw);
for (var k = 0; k < buckets.length; k++) buckets[k].length = 0;
var n = P.n, span = 0.9;
for (var i = 0; i < n; i++) {
var x = P.x[i] * cs - P.y[i] * sn, y = P.x[i] * sn + P.y[i] * cs, z = P.z[i];
var y2 = y * ct - z * st, z2 = y * st + z * ct;
var x3 = x * cyaw + z2 * syaw, z3 = -x * syaw + z2 * cyaw;
var f = 1 / (1.6 - z3 * 0.9);
X[i] = cx + x3 * R * f * 1.6; Y[i] = cy + y2 * R * f * 1.6;
var d = Math.max(0, Math.min(0.999, (z3 + span) / (2 * span)));
buckets[Math.floor(d * NB) * NC + P.c[i]].push(i);
}
for (var db = 0; db < NB; db++) {
var depth = (db + 0.5) / NB;
ctx.globalAlpha = 0.18 + depth * 0.82;
var sz = 0.7 + depth * 1.1;
for (var c = 0; c < NC; c++) {
var list = buckets[db * NC + c];
if (!list.length) continue;
ctx.fillStyle = c === 1 ? opts.accent : COLS[c];
for (var j = 0; j < list.length; j++) {
var m = list[j], s = sz * P.s[m];
ctx.fillRect(X[m] - s / 2, Y[m] - s / 2, s, s);
}
}
}
ctx.globalAlpha = 1;
piece.style.setProperty("--px", (tx * -10).toFixed(1) + "px");
piece.style.setProperty("--py", (ty * -10).toFixed(1) + "px");
piece.style.setProperty("--pr", (rot * 20).toFixed(1) + "deg");
}
function frame(t) {
raf = 0;
if (dead || !visible) return;
var dt = last ? Math.min(0.05, (t - last) / 1000) : 0.016; last = t;
rot += dt * 0.12 * opts.speed;
var k = Math.min(1, dt * 4);
tx += (ttx - tx) * k; ty += (tty - ty) * k;
draw();
raf = requestAnimationFrame(frame);
}
function start() { if (!reduce && !raf && visible && !dead) { last = 0; raf = requestAnimationFrame(frame); } }
function pm(e) {
if (e.pointerType === "touch") return;
var r = wrap.getBoundingClientRect();
ttx = Math.max(-1, Math.min(1, ((e.clientX - r.left) / r.width - 0.5) * 2));
tty = Math.max(-1, Math.min(1, ((e.clientY - r.top) / r.height - 0.5) * 2));
if (reduce) { tx = ttx; ty = tty; draw(); }
}
function pl() { ttx = 0; tty = 0; if (reduce) { tx = 0; ty = 0; draw(); } }
wrap.addEventListener("pointermove", pm);
wrap.addEventListener("pointerleave", pl);
var io = null, ro = null;
if (window.IntersectionObserver) {
io = new IntersectionObserver(function (es) {
visible = es[es.length - 1].isIntersecting;
if (visible) start(); else if (raf) { cancelAnimationFrame(raf); raf = 0; }
});
io.observe(wrap);
}
if (window.ResizeObserver) { ro = new ResizeObserver(size); ro.observe(wrap); }
function update(next) {
var prev = opts;
opts = Object.assign({ background: "#0c1e18", accent: "#e6ff55", count: 5000, speed: 0.5, tilt: 0.6, arms: 3 }, opts, next || {});
opts.count = Math.max(1000, Math.min(9000, Math.round(opts.count)));
opts.arms = Math.max(2, Math.min(5, Math.round(opts.arms)));
wrap.style.background = opts.background;
piece.style.color = opts.accent;
if (!P || prev.count !== opts.count || prev.arms !== opts.arms) build();
if (W) draw();
}
update(o);
size();
start();
return {
update: update,
destroy: function () {
dead = true;
if (raf) cancelAnimationFrame(raf);
raf = 0;
wrap.removeEventListener("pointermove", pm);
wrap.removeEventListener("pointerleave", pl);
if (io) io.disconnect();
if (ro) ro.disconnect();
}
};
}
// Usage
var fx = mount(document.querySelector("#my-root"), {"accent":"#e6ff55","count":5000,"speed":0.5,"tilt":0.6,"arms":3});How it works
Particles are generated once from a seeded random sequence: each gets a radius, an arm, and an angle that winds outward with radius, plus a little scatter and a thin vertical offset so the disc has thickness. A dense core is added near the centre. Every frame each point is rotated by the spin angle, tipped toward the viewer by the tilt, turned slightly by the pointer, then projected with a simple perspective divide. Depth sets brightness and size, and points are grouped into depth buckets by colour so the canvas draws far to near with only a few fillStyle changes instead of sorting thousands of items. Core points are cream and lime, the outer arms fade into green.
Accessibility and performance
The canvas and the puzzle piece are decorative and hidden from assistive tech, so put any real content in HTML above them. Reduced motion draws one still frame and the pointer only re-draws that frame, with no animation loop. Touch pointers are ignored. The motion is slow and never flashes.
Cost scales with the particle count: 5000 points is a few thousand small fillRect calls per frame, fine on laptops and phones. Lower the count for older mobile devices. The canvas is capped at 2x pixel ratio, the loop pauses when scrolled off-screen, and it resizes with a ResizeObserver.
| Section | Background |
|---|---|
| Techniques | Pointer interaction, Parallax and depth |
| Stack | Vanilla JS, Canvas 2D |
| Works with | React, Next.js, Vanilla |
| Dependencies | None |
| Status | tested |
| Inspired by | Galaxy background (general idea only, original code) by React Bits on reactbits.dev. Original code, written from scratch. |
Find this technique on a real site
Want to see this technique in the wild? Check any site with the free tools, then build your own version from this page.
Check which animation library a site uses · Extract a site's design tokens · Browse site breakdowns
Licence and provenance
Free to use in personal and client projects during the preview. Final licence terms will be published here before any paid tier starts; ask before reselling the code as a collection.
This is original code. The technique is a common one that we studied by measuring award-winning sites with Enrichr; no third-party code, shaders, fonts or images are used.
More elements
FAQ
Why canvas 2D and not WebGL?
A few thousand square points project and draw quickly in 2D, there is no context to lose and no fallback needed. WebGL would only pay off past tens of thousands of points.
Can I change the arm shape?
Yes. The winding factor in build() controls how tightly the arms curl, and the scatter term controls how fuzzy they are.
Does it fill any container?
Yes. It fills its parent box and re-measures on resize, so it works inside overflow hidden panels and hero sections.