3D & Experimental
ParticleGalaxy
A tilted spiral galaxy of 56,000 single stars with dark dust lanes. Each star runs its own ellipse and the arms come from their crowding, so they never wind up. The mouse bends the light like a heavy unseen mass. WebGL, no three.js.
Download
- React
- Tailwind CSS v4
- motion
components/ui/particle-galaxy.tsx
"use client";
import { useEffect, useRef } from "react";
import { useReducedMotion } from "motion/react";
type ParticleGalaxyProps = {
/** Number of stars. Keep it under 120000 on phones. */
count?: number;
/** Six-digit hex colour of the young stars in the arms. */
arms?: string;
/** Six-digit hex colour of the old stars at the centre. */
core?: string;
/** Six-digit hex colour of the small gas clouds along the arms. */
gas?: string;
/** Six-digit hex colour of the sky behind. */
background?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Name read out by screen readers. */
label?: string;
/** Set the size here. */
className?: string;
};
const MAX_PIXELS = 1600000;
/* The arms are not drawn. Each star runs its own ellipse, each ellipse is turned a little more than the one inside it, and stars linger at the ends of their ellipse. The crowding reads as two arms that never wind up. */
const VERTEX = `attribute vec4 s;
uniform vec2 turn,lens;
uniform vec3 arm,core,gas;
uniform float time,aspect,height,bend;
varying vec4 tone;
void main(){
float kind=floor(s.w),mag=fract(s.w);
vec2 ndc;
float w=1.;
if(kind>3.5&&kind<4.5){
ndc=s.xy;
tone=vec4(vec3(.32+mag*.5)*vec3(.9,.95,1.),1.);
gl_PointSize=(1.2+mag*1.5)*height/640.;
}else{
float a=s.x,th=s.y+time*.11/(.12+a);
float pull=kind<.5?.3:kind<1.5?.9:.96;
float phi=th-pull*.5*sin(2.*th);
float e=.32*smoothstep(0.,.18,a)*(1.-.4*a);
vec2 q=vec2(a*cos(phi),a*(1.-e)*sin(phi));
float tw=1.8*log(a+.05)+time*.012-(kind>2.5?.17:0.);
float c=cos(tw),n=sin(tw);
vec3 p=vec3(q.x*c-q.y*n,s.z*(.016+a*.72*exp(-a*a*12.)),q.x*n+q.y*c);
c=cos(turn.x);n=sin(turn.x);
p=vec3(c*p.x+n*p.z,p.y,-n*p.x+c*p.z);
c=cos(turn.y);n=sin(turn.y);
p=vec3(p.x,c*p.y-n*p.z,n*p.y+c*p.z);
p=vec3(.91*p.x-.41*p.y,.41*p.x+.91*p.y,p.z);
w=1.-p.z/2.9;
ndc=p.xy/w*min(aspect*.92,1.22)*vec2(1./aspect,1.);
float inner=exp(-a*a*18.);
float edge=smoothstep(1.02,.55,a);
if(kind>4.5){
tone=vec4(mix(arm,core,inner)*.012*edge,1.);
gl_PointSize=(26.+mag*30.)*height/640./w;
}else if(kind<.5){
tone=vec4(mix(mix(arm,core,.72),core*1.08,inner)*.24*(.5+mag)*edge,1.);
gl_PointSize=(1.7+mag*1.5)*height/640./w;
}else if(kind<1.5){
tone=vec4(arm*.5*(.45+mag)*edge,1.);
gl_PointSize=(1.6+mag*1.8)*height/640./w;
}else if(kind<2.5){
tone=vec4(gas*.13*edge,1.);
gl_PointSize=(4.+mag*7.)*height/640./w;
}else{
tone=vec4(0.,0.,0.,.055*(1.-inner*.85)*edge);
gl_PointSize=(15.+mag*18.)*height/640./w;
}
}
vec2 v=ndc-lens;
v.x*=aspect;
vec2 shift=v/(dot(v,v)+.0016)*bend;
shift.x/=aspect;
gl_Position=vec4(ndc+shift,0.,1.);
}`;
const FRAGMENT = `precision mediump float;
varying vec4 tone;
void main(){
vec2 c=gl_PointCoord-.5;
gl_FragColor=tone*exp(-dot(c,c)*14.);
}`;
const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
function seeded(start: number) {
let n = start;
return () => {
n = (n + 0x6d2b79f5) | 0;
let t = Math.imul(n ^ (n >>> 15), 1 | n);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/* radius, phase, height, kind + brightness. Stars come first, the dark dust last, so it can be drawn over them. */
function buildSky(count: number) {
const random = seeded(7);
const gauss = () => (random() + random() + random() - 1.5) / 0.75;
const stars = Math.round(count);
const dust = Math.round(count * 0.16);
const data = new Float32Array((stars + dust) * 4);
for (let i = 0; i < stars + dust; i++) {
const roll = random();
const kind = i >= stars ? 3 : roll < 0.02 ? 4 : roll < 0.4 ? 1 : roll < 0.415 ? 2 : roll < 0.5 ? 5 : 0;
const mag = random() * 0.98;
const round = kind === 0 ? Math.pow(random(), 1.7) : Math.min(1, 0.07 - 0.36 * Math.log(1 - random() * 0.96));
const o = i * 4;
if (kind === 4) {
data[o] = random() * 2 - 1;
data[o + 1] = random() * 2 - 1;
} else {
data[o] = round;
data[o + 1] = random() * Math.PI * 2;
data[o + 2] = gauss();
}
data[o + 3] = kind + mag;
}
return { data, stars, dust };
}
function program(gl: WebGLRenderingContext) {
const made = gl.createProgram();
for (const [type, text] of [[gl.VERTEX_SHADER, VERTEX], [gl.FRAGMENT_SHADER, FRAGMENT]] as const) {
const shader = gl.createShader(type);
if (!made || !shader) return null;
gl.shaderSource(shader, text);
gl.compileShader(shader);
gl.attachShader(made, shader);
}
if (!made) return null;
gl.linkProgram(made);
return gl.getProgramParameter(made, gl.LINK_STATUS) ? made : null;
}
/* ParticleGalaxy: a tilted spiral galaxy of single stars with dark dust lanes. The mouse bends its light like a heavy unseen mass; drag or arrow keys tilt it. Runs only while on screen. */
export function ParticleGalaxy({
count = 56000,
arms = "#a9c7ff",
core = "#ffd9a6",
gas = "#ff6f91",
background = "#04050b",
speed = 1,
label = "A spiral galaxy of stars seen at an angle. Drag or use arrow keys to tilt it.",
className = "aspect-square w-80",
}: ParticleGalaxyProps) {
const reduce = useReducedMotion();
const boxRef = useRef<HTMLDivElement>(null);
const plainRef = useRef<HTMLDivElement>(null);
const hues = [arms, core, gas, background].join();
useEffect(() => {
const box = boxRef.current;
const plain = plainRef.current;
if (!box || !plain) return;
const canvas = document.createElement("canvas");
canvas.setAttribute("aria-hidden", "true");
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%";
const gl = canvas.getContext("webgl", { alpha: false, antialias: false });
const made = gl && program(gl);
if (!gl || !made) return;
gl.useProgram(made);
const { data, stars, dust } = buildSky(count);
gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const slot = gl.getAttribLocation(made, "s");
gl.enableVertexAttribArray(slot);
gl.vertexAttribPointer(slot, 4, gl.FLOAT, false, 0, 0);
const at = (name: string) => gl.getUniformLocation(made, name);
const [one, two, three, sky] = hues.split(",").map(rgb);
gl.uniform3fv(at("arm"), one);
gl.uniform3fv(at("core"), two);
gl.uniform3fv(at("gas"), three);
gl.clearColor(sky[0], sky[1], sky[2], 1);
gl.enable(gl.BLEND);
let raf = 0;
let last = 0;
let inView = false;
let time = reduce ? 40 : 0;
let yaw = 0;
let pitch = -1.14;
let drift = 0;
let lensX = 0;
let lensY = 0;
let bend = 0;
let goalBend = 0;
let held = false;
let lastX = 0;
let lastY = 0;
const draw = () => {
gl.uniform2f(at("turn"), yaw, pitch);
gl.uniform2f(at("lens"), lensX, lensY);
gl.uniform1f(at("bend"), bend);
gl.uniform1f(at("time"), time % 4000);
gl.clear(gl.COLOR_BUFFER_BIT);
gl.blendFunc(gl.ONE, gl.ONE);
gl.drawArrays(gl.POINTS, 0, stars);
gl.blendFunc(gl.ZERO, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.POINTS, stars, dust);
};
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000 || 0);
last = now;
time += dt * speed;
if (!held) {
yaw += drift * dt;
drift *= 1 - Math.min(1, dt * 2);
}
bend += (goalBend - bend) * Math.min(1, dt * 5);
draw();
raf = inView ? requestAnimationFrame(frame) : 0;
};
const start = () => {
if (inView && !raf && !reduce) raf = requestAnimationFrame(frame);
};
const fit = () => {
const { width, height } = box.getBoundingClientRect();
if (!width || !height) return;
const scale = Math.min(Math.max(window.devicePixelRatio, 1.5), 2, Math.sqrt(MAX_PIXELS / (width * height)));
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(at("aspect"), width / height);
gl.uniform1f(at("height"), canvas.height);
draw();
};
const press = (e: PointerEvent) => {
held = true;
goalBend = 0;
lastX = e.clientX;
lastY = e.clientY;
box.setPointerCapture(e.pointerId);
};
const move = (e: PointerEvent) => {
if (held) {
const dx = e.clientX - lastX;
const dy = e.clientY - lastY;
lastX = e.clientX;
lastY = e.clientY;
yaw += dx * 0.007;
pitch = Math.min(-0.35, Math.max(-1.42, pitch + dy * 0.005));
drift = dx * 0.3;
} else if (e.pointerType === "mouse") {
const r = box.getBoundingClientRect();
lensX = ((e.clientX - r.left) / r.width - 0.5) * 2;
lensY = (0.5 - (e.clientY - r.top) / r.height) * 2;
goalBend = 0.007;
if (reduce) bend = goalBend;
}
if (reduce) draw();
};
const release = () => {
held = false;
};
const leave = () => {
goalBend = 0;
if (reduce) {
bend = 0;
draw();
}
};
const key = (e: KeyboardEvent) => {
const turn = { ArrowLeft: -1, ArrowRight: 1 }[e.key] ?? 0;
const tilt = { ArrowUp: -1, ArrowDown: 1 }[e.key] ?? 0;
if (!turn && !tilt) return;
e.preventDefault();
yaw += turn * 0.14;
pitch = Math.min(-0.35, Math.max(-1.42, pitch + tilt * 0.08));
if (reduce) draw();
};
fit();
box.append(canvas);
plain.hidden = true;
const sized = new ResizeObserver(fit);
sized.observe(box);
const watch = new IntersectionObserver(([entry]) => {
inView = entry.isIntersecting;
start();
});
watch.observe(box);
const lost = () => {
canvas.remove();
plain.hidden = false;
};
canvas.addEventListener("webglcontextlost", lost);
box.addEventListener("pointerdown", press);
box.addEventListener("pointermove", move);
box.addEventListener("pointerup", release);
box.addEventListener("pointercancel", release);
box.addEventListener("pointerleave", leave);
box.addEventListener("keydown", key);
return () => {
cancelAnimationFrame(raf);
sized.disconnect();
watch.disconnect();
canvas.removeEventListener("webglcontextlost", lost);
box.removeEventListener("pointerdown", press);
box.removeEventListener("pointermove", move);
box.removeEventListener("pointerup", release);
box.removeEventListener("pointercancel", release);
box.removeEventListener("pointerleave", leave);
box.removeEventListener("keydown", key);
gl.getExtension("WEBGL_lose_context")?.loseContext();
canvas.remove();
plain.hidden = false;
};
}, [count, hues, speed, reduce]);
return (
<div
ref={boxRef}
role="application"
tabIndex={0}
aria-label={label}
className={`relative cursor-grab touch-pan-y overflow-hidden active:cursor-grabbing ${className}`}
style={{ background }}
>
<div
ref={plainRef}
className="absolute left-1/2 top-1/2 h-[30%] w-[72%] -translate-x-1/2 -translate-y-1/2 -rotate-[24deg] rounded-[50%]"
style={{ background: `radial-gradient(closest-side, ${core}, ${arms}66 45%, transparent)` }}
/>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/particle-galaxy.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/particle-galaxy-demo.tsx
import { ParticleGalaxy } from "@/components/ui/particle-galaxy"; export default function ParticleGalaxyExample() { return <ParticleGalaxy className="size-full min-h-[260px]" />; }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| count | number | 56000 | Number of stars. Keep it under 120000 on phones. |
| arms | string | "#a9c7ff" | Six-digit hex colour of the young stars in the arms. |
| core | string | "#ffd9a6" | Six-digit hex colour of the old stars at the centre. |
| gas | string | "#ff6f91" | Six-digit hex colour of the small gas clouds. |
| background | string | "#04050b" | Six-digit hex colour of the sky behind. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "A spiral galaxy of stars..." | Name read out by screen readers. |
| className | string | "aspect-square w-80" | Set the size here. |