Some projects start with a plan. This one started with a question that wouldn’t leave me alone: what’s the most alive thing I can build with plain JavaScript?
Not a to-do app. Not another landing page. Something that moves, breathes, and reacts when you touch it β something that makes people stop scrolling and go, “wait, how is this running in a browser?”
That question turned into Stellix Galaxy, and honestly, it’s one of the most satisfying things I’ve ever built.
So what is it?
Stellix Galaxy is an interactive particle universe that runs entirely in your browser. Two thousand glowing particles float on a black canvas, and they’re not just random dots drifting around. They have homes. They have targets. They arrange themselves into shapes β my brand name STELLIX spelled out in light, a beating heart, a five-pointed star, a spiral galaxy with three rotating arms, even a little Saturn with rings made of hundreds of tiny points.
And here’s my favorite part: touch them.
Drag your finger across the screen and the particles scatter like startled fish. Press and hold, and they push away even harder, carving a glowing hole around your fingertip. The moment you let go, every single particle finds its way back home, settling into the shape like nothing happened. It never gets old. I’ve caught myself just poking at it for ten minutes straight when I was supposed to be “testing.”
There’s also a rain mode, where all 2,000 particles give up their shapes and simply fall β an endless neon rainfall you can swipe through. It serves no purpose. I added it because it felt good. Sometimes that’s reason enough.
The rules I set for myself
Before writing a single line, I made three rules:
One file. Everything β HTML, CSS, JavaScript β lives in a single .html file you can open anywhere. No build step, no setup, no “npm install” taking five minutes of your life.
Zero libraries. No Three.js, no p5.js, no particle engine from GitHub. Just the raw Canvas API and vanilla JavaScript. If the browser ships it for free, I’m allowed to use it. Otherwise, I write it myself.
It has to feel alive. Smooth on a phone, not just a gaming PC. If it stutters on a mid-range Android device, it’s not done.
These constraints made the project harder, and that’s exactly why the result feels special. When you can’t reach for a library, you’re forced to actually understand what’s happening under the hood.
How the magic actually works (in plain words)
People assume something like this needs complicated math. It really doesn’t. The core idea is embarrassingly simple: every particle is attached to its target by an invisible spring.
Each frame, every particle looks at where it is, looks at where it should be, and nudges its velocity a tiny bit in that direction. Then friction slows it down so it doesn’t overshoot forever. That’s it. That one tiny formula, repeated 2,000 times per frame, 60 times per second, creates all the organic, floaty, alive movement you see.
The touch interaction is the same trick in reverse. Your finger is just a force field. Any particle inside its radius gets pushed away, stronger the closer it is. Springs pull home, finger pushes away, and the fight between those two forces is what makes it feel like the particles have a mind of their own.
For the STELLIX text shape, I did something I’m a little proud of. I draw the word on a hidden canvas nobody ever sees, then scan its pixels one by one. Wherever the scan finds an opaque pixel, that becomes a particle’s home coordinate. So the particles aren’t following some hardcoded list of points β they’re literally reading the text and rebuilding it out of light. The same technique works for any word, any font, any logo.
The heart comes from a classic parametric equation mathematicians have loved for decades. The galaxy is three spiral arms with a bit of random scatter so it looks natural instead of mechanical. Saturn is just a filled circle plus a squashed ring of points. Simple ingredients, careful seasoning.
The glow trails were the cheapest trick of all: instead of clearing the canvas fully each frame, I paint over it with a semi-transparent black rectangle. Old frames fade out slowly instead of vanishing, and suddenly every particle drags a ribbon of light behind it. One line of code. Massive difference.
The part nobody warns you about
Performance nearly broke me. My first version ran beautifully on my laptop and turned into a slideshow on my phone. Two thousand particles means two thousand physics updates and two thousand draw calls every frame, and mobile browsers do not forgive waste.
The fixes were small but they added up: drawing tiny rectangles instead of arcs where possible, capping the device pixel ratio so 4K screens don’t quadruple the workload, dropping the particle count to 1,100 on small screens, and using squared distances to avoid thousands of square-root calculations per frame. None of these are glamorous. Together, they took the phone experience from “painful” to “buttery.”
If you’re learning creative coding, remember this: the difference between a cool demo and a great one is almost never a fancy feature. It’s the boring optimization work nobody sees.
Why I’m sharing this
Because I think too many beginners believe this kind of thing is out of reach β that you need WebGL wizardry or a computer science degree to make pixels dance. You don’t. You need a canvas, a loop, one spring formula, and the patience to tweak numbers until it feels right. The feeling part took me longer than the code part, and I’d argue it mattered more.
Everything auto-morphs too β leave it alone and every seven seconds the universe quietly rearranges itself into the next shape. I’ve left it running on a second monitor like a lava lamp. Zero regrets.
The colors cycle through an aurora palette β green melting into blue melting into purple β because a static color felt dead next to all that motion. Faster particles glow brighter, so when you swipe through the swarm, your gesture leaves a streak of extra-bright light. Tiny detail. Huge payoff.
Try it yourself. Open it on your phone, drag your finger through the galaxy, and watch two thousand little lights refuse to stay scattered. Then open the file, read the code, and steal every idea you find in there. That’s what it’s for.
If you build your own version β your name in particles, your own shapes, your own colors β tag me. I genuinely want to see it.
More experiments like this coming. This one was too much fun to be the last.
β Coding Stellix One file. Zero libraries. Infinite particles.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Stellix Galaxy β¨ β Interactive Particle Universe | Coding Stellix</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@500;700;900&family=Manrope:wght@400;600;700&display=swap" rel="stylesheet">
<style>
/* ============================================================
STELLIX GALAXY β¨ β Interactive Particle Universe
A Coding Stellix Project
2000 living particles morph into shapes & words.
Touch them β they run away. Release β they return home.
============================================================ */
:root{
--bg:#020204;
--aurora-1:#00ff9d;
--aurora-2:#00b3ff;
--aurora-3:#9d4edd;
--ink:#f4fbff;
--dim:#5f6c7d;
--display:'Unbounded',sans-serif;
--ui:'Manrope',sans-serif;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;overflow:hidden}
body{
background:var(--bg);
color:var(--ink);
font-family:var(--ui);
-webkit-tap-highlight-color:transparent;
touch-action:none;
}
canvas{position:fixed;inset:0;display:block}
/* ---------- top brand ---------- */
.brand{
position:fixed;top:18px;left:50%;transform:translateX(-50%);
z-index:10;text-align:center;pointer-events:none;
animation:fadeDown 1s ease .3s both;
}
@keyframes fadeDown{from{opacity:0;transform:translate(-50%,-12px)}to{opacity:1;transform:translate(-50%,0)}}
.brand .t{
font-family:var(--display);font-weight:900;
font-size:clamp(.85rem,3vw,1.1rem);letter-spacing:.24em;
background:linear-gradient(90deg,var(--aurora-1),var(--aurora-2),var(--aurora-3));
-webkit-background-clip:text;background-clip:text;color:transparent;
}
.brand .s{
font-size:.6rem;letter-spacing:.4em;text-transform:uppercase;color:var(--dim);
margin-top:.25rem;
}
/* ---------- hint ---------- */
.hint{
position:fixed;bottom:96px;left:50%;transform:translateX(-50%);
z-index:10;font-size:.72rem;color:var(--dim);letter-spacing:.1em;
pointer-events:none;white-space:nowrap;
animation:hintPulse 2.6s ease infinite;
}
@keyframes hintPulse{0%,100%{opacity:.45}50%{opacity:1}}
/* ---------- shape dock ---------- */
.dock{
position:fixed;bottom:22px;left:50%;transform:translateX(-50%);
z-index:10;display:flex;gap:.55rem;
background:rgba(255,255,255,.045);
border:1px solid rgba(255,255,255,.1);
border-radius:999px;padding:.5rem .6rem;
backdrop-filter:blur(16px);-webkit-backdrop-filter:blur(16px);
animation:fadeUp 1s ease .5s both;
}
@keyframes fadeUp{from{opacity:0;transform:translate(-50%,14px)}to{opacity:1;transform:translate(-50%,0)}}
.dock button{
width:46px;height:46px;border-radius:50%;
border:1px solid transparent;
background:transparent;color:var(--ink);
font-size:1.15rem;cursor:pointer;
transition:background .2s,transform .2s,border-color .2s;
}
.dock button:hover{transform:translateY(-4px) scale(1.1);background:rgba(255,255,255,.08)}
.dock button.sel{
background:linear-gradient(135deg,rgba(0,255,157,.22),rgba(0,179,255,.22));
border-color:rgba(0,255,157,.5);
box-shadow:0 0 18px rgba(0,255,157,.35);
}
.dock button:focus-visible{outline:2px solid var(--aurora-1);outline-offset:2px}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.01ms!important}
}
</style>
</head>
<body>
<canvas id="space"></canvas>
<div class="brand">
<div class="t">STELLIX GALAXY</div>
<div class="s">A Coding Stellix Universe</div>
</div>
<p class="hint">β¦ touch & drag the particles β they're alive β¦</p>
<div class="dock" id="dock">
<button data-shape="text" class="sel" aria-label="Stellix text">β³οΈ</button>
<button data-shape="heart" aria-label="Heart">β€οΈ</button>
<button data-shape="star" aria-label="Star">β</button>
<button data-shape="galaxy" aria-label="Spiral galaxy">π</button>
<button data-shape="saturn" aria-label="Planet">πͺ</button>
<button data-shape="rain" aria-label="Free rain">π§οΈ</button>
</div>
<script>
/* =====================================================
STELLIX GALAXY β particle engine
A Coding Stellix Project
===================================================== */
const cv = document.getElementById('space');
const cx = cv.getContext('2d');
let W, H, DPR, CENTER;
const isMobile = matchMedia('(max-width: 640px)').matches;
const COUNT = isMobile ? 1100 : 2000;
function resize(){
DPR = Math.min(devicePixelRatio || 1, 2);
W = cv.width = innerWidth * DPR;
H = cv.height = innerHeight * DPR;
cv.style.width = innerWidth + 'px';
cv.style.height = innerHeight + 'px';
CENTER = { x: W / 2, y: H / 2 };
setShape(currentShape, true);
}
addEventListener('resize', resize);
/* ---------- particles ---------- */
const parts = [];
for (let i = 0; i < COUNT; i++){
parts.push({
x: Math.random(), y: Math.random(), // set properly after first resize
vx: 0, vy: 0,
tx: 0, ty: 0,
hue: Math.random() * 360,
size: 0,
wob: Math.random() * Math.PI * 2,
wobSp: .01 + Math.random() * .03,
});
}
/* ---------- shape target generators ---------- */
function targetsFromCanvasText(str){
const off = document.createElement('canvas');
const s = 320;
off.width = s * 2; off.height = s;
const oc = off.getContext('2d');
oc.fillStyle = '#fff';
oc.font = `900 ${s * .34}px Unbounded, Arial Black, sans-serif`;
oc.textAlign = 'center'; oc.textBaseline = 'middle';
oc.fillText(str, s, s / 2);
const data = oc.getImageData(0, 0, off.width, off.height).data;
const pts = [];
const step = 3;
for (let y = 0; y < off.height; y += step){
for (let x = 0; x < off.width; x += step){
if (data[(y * off.width + x) * 4 + 3] > 120){
pts.push({ x: (x - s) / s, y: (y - s / 2) / s }); // normalized around 0
}
}
}
return pts;
}
function shapeTargets(name){
const pts = [];
const N = COUNT;
if (name === 'text'){
const raw = targetsFromCanvasText('STELLIX');
for (let i = 0; i < N; i++){
const p = raw[i % raw.length];
pts.push({ x: p.x * 1.6, y: p.y * 1.6 });
}
}
else if (name === 'heart'){
for (let i = 0; i < N; i++){
const t = (i / N) * Math.PI * 2;
const r = .045 * (1 + (i % 5) * .04);
pts.push({
x: r * 16 * Math.pow(Math.sin(t), 3),
y: -r * (13 * Math.cos(t) - 5 * Math.cos(2*t) - 2 * Math.cos(3*t) - Math.cos(4*t)),
});
}
}
else if (name === 'star'){
for (let i = 0; i < N; i++){
const a = (i / N) * Math.PI * 2 * 5; // wrap 5 times β filled
const k = ((i * 5) % N) / N * Math.PI * 2;
const spike = .38 + .42 * Math.pow(Math.abs(Math.cos(k * 2.5)), 3);
const rr = spike * (0.25 + .75 * ((i % 7) / 7));
pts.push({ x: Math.cos(k - Math.PI/2) * rr, y: Math.sin(k - Math.PI/2) * rr });
}
}
else if (name === 'galaxy'){
for (let i = 0; i < N; i++){
const arm = i % 3;
const t = (i / N) * 6.5;
const a = t * 2.2 + arm * (Math.PI * 2 / 3);
const r = .06 + t * .1 + (Math.random() - .5) * .05;
pts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r * .82 });
}
}
else if (name === 'saturn'){
for (let i = 0; i < N; i++){
if (i < N * .55){ // planet body
const a = Math.random() * Math.PI * 2;
const r = Math.sqrt(Math.random()) * .34;
pts.push({ x: Math.cos(a) * r, y: Math.sin(a) * r });
} else { // rings
const a = Math.random() * Math.PI * 2;
const r = .46 + Math.random() * .16;
pts.push({ x: Math.cos(a) * r * 1.25, y: Math.sin(a) * r * .34 });
}
}
}
else { // rain β free fall mode handled in update; park targets offscreen top
for (let i = 0; i < N; i++){
pts.push({ x: (Math.random() - .5) * 2.2, y: -1.4 - Math.random() });
}
}
return pts;
}
let currentShape = 'text';
function setShape(name, instant){
currentShape = name;
const scale = Math.min(W, H) * .42;
const t = shapeTargets(name);
for (let i = 0; i < COUNT; i++){
parts[i].tx = CENTER.x + t[i].x * scale;
parts[i].ty = CENTER.y + t[i].y * scale;
if (instant && name !== 'rain'){
parts[i].x = parts[i].tx + (Math.random() - .5) * 40 * DPR;
parts[i].y = parts[i].ty + (Math.random() - .5) * 40 * DPR;
}
}
document.querySelectorAll('.dock button').forEach(b =>
b.classList.toggle('sel', b.dataset.shape === name)
);
}
/* ---------- pointer ---------- */
const pointer = { x: -9999, y: -9999, down: false };
function setPtr(e){
pointer.x = e.clientX * DPR;
pointer.y = e.clientY * DPR;
}
addEventListener('pointermove', setPtr);
addEventListener('pointerdown', e => { pointer.down = true; setPtr(e); });
addEventListener('pointerup', () => { pointer.down = false; pointer.x = -9999; pointer.y = -9999; });
addEventListener('pointerleave',() => { pointer.x = -9999; pointer.y = -9999; });
/* ---------- dock ---------- */
document.getElementById('dock').addEventListener('click', e => {
const b = e.target.closest('button');
if (b) setShape(b.dataset.shape);
});
/* auto-morph every 7s if user hasn't touched dock recently */
const ORDER = ['text','heart','star','galaxy','saturn'];
let autoIdx = 0, lastManual = 0;
document.getElementById('dock').addEventListener('click', () => lastManual = performance.now());
setInterval(() => {
if (performance.now() - lastManual < 12000) return;
autoIdx = (autoIdx + 1) % ORDER.length;
setShape(ORDER[autoIdx]);
}, 7000);
/* ---------- update & render ---------- */
let hueBase = 140;
function frame(){
hueBase = (hueBase + .25) % 360;
// soft fade for glow trails
cx.fillStyle = 'rgba(2,2,4,.32)';
cx.fillRect(0, 0, W, H);
cx.globalCompositeOperation = 'lighter';
const rainMode = currentShape === 'rain';
const repelR = 130 * DPR;
for (const p of parts){
if (rainMode){
p.vy += .12 * DPR;
p.x += p.vx; p.y += p.vy;
p.vx *= .99;
if (p.y > H + 20){ p.y = -20; p.vy = 1 + Math.random() * 3; p.x = Math.random() * W; }
} else {
// spring toward target
p.vx += (p.tx - p.x) * .012;
p.vy += (p.ty - p.y) * .012;
}
// pointer repulsion (stronger when pressed)
const dx = p.x - pointer.x, dy = p.y - pointer.y;
const d2 = dx * dx + dy * dy;
if (d2 < repelR * repelR){
const d = Math.sqrt(d2) || 1;
const f = (repelR - d) / repelR * (pointer.down ? 3.2 : 1.6) * DPR;
p.vx += dx / d * f;
p.vy += dy / d * f;
}
p.vx *= .9; p.vy *= .9;
p.x += p.vx; p.y += p.vy;
// breathing wobble
p.wob += p.wobSp;
const tw = .6 + Math.sin(p.wob) * .4;
// aurora hue: base drift + per-particle offset
const hue = (hueBase + p.hue * .25) % 360;
const speed = Math.min(14, Math.abs(p.vx) + Math.abs(p.vy));
const size = (isMobile ? 1.3 : 1.6) * DPR * tw + speed * .12;
cx.fillStyle = `hsl(${hue} 100% ${58 + speed * 2}%)`;
cx.fillRect(p.x, p.y, size, size);
}
cx.globalCompositeOperation = 'source-over';
requestAnimationFrame(frame);
}
resize();
frame();
</script>
</body>
</html>



