Galaxy Spiral
A 2D canvas animates 400 stars with differential rotation across 3 spiral arms — luminous radial core and persistence via alpha compositing.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Atmosphere has 57 effects, including 10 free. Explore the category →
3 usage examples



How it works
On load, 400 stars are placed using a spiral formula. Each star belongs to one of 3 arms (index % 3); its initial angle is arm/3 × 2π + dist/80. The dist/80 term is the radial twist: the farther a star is from the center, the more it wraps around its arm. A ±0.5 rad jitter scatters stars naturally around the arm axis. Distance from center is sampled randomly up to Math.min(w,h) × 0.42.
The RAF loop updates angle += speed every frame. Speed decreases with distance: 3×10⁻⁴ + 0.001 × (1 − r/r_max) — this is the differential rotation characteristic of real galaxies (the core rotates faster than the outer arms). The canvas is not cleared between frames: a semi-transparent fillRect with alpha 0.08 progressively covers past positions, producing a persistence trail via alpha compositing — it takes ~12 frames (~200 ms at 60 fps) for a position to fully disappear.
A radial gradient is redrawn every frame over the central core (fixed radius 30 px): it goes from rgba(200,180,255,0.1) to transparent, simulating the glow of a galactic bulge. Each star is an arc() of 0.5–2 px painted in one of 5 hardcoded colors ["#fff","#a78bfa","#ec4899","#fbbf24","#60a5fa"], with individual opacity of 0.3–0.8.
The effect initializes on id="psGalaxy" via getElementById — one instance per page in the source code. No IntersectionObserver cuts the RAF off-screen (loop runs continuously). No public API is exposed: parameters are JS constants to modify directly in the source.
Accessibility
- prefers-reduced-motion not handled: the animation runs unconditionally, with no static fallback. At integration time, wrap the init in
if (!matchMedia('(prefers-reduced-motion:reduce)').matches) {…}and hide the canvas via@media (prefers-reduced-motion:reduce){.ps-canvas{display:none}}. - The
<canvas>does not carryaria-hidden="true"in the source — add it so screen readers skip the graphical element. - No
role="img"oraria-labelon the container — supply one at integration time (e.g.aria-label="Animated spiral galaxy — decoration"). - The effect is purely decorative and non-interactive: no keyboard handlers, no focus targets, no pointer events.
- Colored stars on an
#0a0a0fbackground provide high visual contrast (decoration only — no text at contrast risk is rendered inside the canvas).
Browser compatibility
Requires Canvas 2D Context and requestAnimationFrame — available in all modern browsers. The CSS uses :has() to style the container (Chrome 105+, Firefox 121+, Safari 15.4+); missing support only affects the wrap's fallback style, not the animation.
If <code>canvas</code> is not supported, the <code>.ps-canvas-wrap</code> renders empty on a dark background. The <code>if (!t) return</code> guard at the top of the IIFE prevents any JS errors.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<div class="ps-canvas-wrap">
<!-- ⚠ id="psGalaxy" is hardcoded in the JS: one instance per page without adaptation -->
<canvas class="ps-canvas" id="psGalaxy" width="392" height="280"
aria-hidden="true"></canvas>
</div>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| l (color palette) | ["#fff","#a78bfa","#ec4899","#fbbf24","#60a5fa"] | Star color array (const l=[…] line). 3 colors for a clean look, 7+ for a festive feel. Each color is manually converted to RGB in the draw loop — if you add a color, add its entry to the if/else conversion block too. |
| 400 (star count) | 400 | Loop size in for(let a=0;a<400;a++). Reduce to 150–200 for a lighter integration on small surfaces or low-power mobile. |
| a % 3 (spiral arms) | 3 | The modulo defines the arm count. Change to a%2 for a barred galaxy, a%5 for a very dense spiral. Update the angle formula accordingly: n/3 → n/N. |
| 80 (radial twist) | 80 | Divisor in dist/80. Lower to 40 for tightly wound compact arms, raise to 150 for a nearly straight spiral. |
| 0.08 (trail alpha) | 0.08 | Alpha of the persistence fillRect (rgba(10,10,15,0.08)). Lower to 0.03 for long ghostly trails, raise to 0.25 for a crisp, trail-free render. |
| 0.42 (max radius) | 0.42 | Factor applied to Math.min(w,h) for the maximum star distance. 0.42 = spiral fits within 84% of the short side. Lower to 0.3 to concentrate, raise to 0.5 to spread to the edges. |
| 30 (core radius) | 30 px | Radius of the central radial gradient (createRadialGradient(cx,cy,0,cx,cy,30)). Raise to 60–80 for a bright quasar-like core; lower to 10 for a discreet center point. |
| 1.5*Math.random()+.5 (star size) | 0.5–2 px | Star radius range. Change to 2.5*Math.random()+1 for larger, more visible stars on big screens, or Math.random()+0.3 for tiny, more realistic points. |
FAQ
fillRect(0,0,w,h) with fillStyle="rgba(10,10,15,0.08)" progressively covers previous positions — the alpha compositing trail technique. At alpha 0.08, a position takes ~12 frames (~200 ms at 60 fps) to fully disappear. Raise this alpha to shorten trails, lower it to lengthen them.const n = a % 3 to const n = a % 4 (or % 5). Update the angle formula: n / 3 * Math.PI * 2 becomes n / 4 * Math.PI * 2. Also raise the twist divisor (e.g. dist/120) to prevent additional arms from overlapping near the center.prefers-reduced-motion. To fix at integration time: wrap the initialization in if (!window.matchMedia('(prefers-reduced-motion:reduce)').matches) { /* init */ }, and hide the canvas via @media (prefers-reduced-motion:reduce){.ps-canvas{display:none}} in CSS.