Galaxy
A spiral galaxy on one canvas — stars threaded onto logarithmic arms, a disc that rotates differentially, and a bright core, all painted in your theme tokens.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/galaxy.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Galaxy" component — a spiral galaxy
painted on one canvas, used as a hero / auth / launch / 404 backdrop. Its only
dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Galaxy(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- count?: number (default 420) — requested star count. The effective count is
min(count, floor(boxArea / density), 1400).
- density?: number (default 420) — CSS px² of canvas required per star; this
is the area cap that keeps a small card from being carpeted.
- speed?: number (default 1) — clamped to [-4, 4]. Negative spins the disc the
other way; 0 freezes it and the rAF loop never starts.
- arms?: number (default 4) — spiral arms, rounded and clamped to 1..8.
- winding?: number (default 0.9) — turns an arm makes between the bulge and
the rim, clamped to [-3, 3]. Negative mirrors the chirality.
- scatter?: number (default 0.55) — half-width of the lit band around an arm,
as a fraction of the half-spacing between arms. Clamped to [0.08, 1]; 1 is
an even disc.
- shear?: number (default 0.5) — clamped to [0, 1]. 0 rotates rigidly.
- tilt?: number (default 58) — inclination in degrees, clamped to [0, 82].
- core?: number (default 0.22) — share of stars seeded into the central bulge,
and the strength of the core glow. Clamped to [0, 0.6]; 0 removes both.
- tone?: "foreground" | "primary" | "muted" | "chart" (default "foreground")
— which semantic token the field is painted with.
- seed?: number (default 5) — integer seed for the star map.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and three observers.
- Clamp every numeric prop up front and treat a non-finite value as the default:
NaN arms would divide the circle into nothing, count 1e6 would freeze the tab,
tilt 140 would mirror the disc, scatter 0 would leave a black box.
Geometry
- Disc radius: maxR = 0.55 * min(width, height / max(cos(tilt), 0.3)). The disc
is an ellipse — y is scaled by squash = cos(tilt) — so the fit rule divides by
the squash to let a tilted, and therefore shorter, disc be wider, with a floor
so a near edge-on disc cannot demand an unbounded radius. 0.55 rather than 0.5
is deliberate: a backdrop should bleed past its box, not sit in it like a logo.
- Store star radii NORMALISED (0..1 of maxR). A resize then needs no pass over
the pool at all: nothing reshuffles and nothing has to be rescaled.
- Radial distribution: bulge stars rn = 0.18 * u^0.85 with a uniform angle; disc
stars rn = 0.18 + 0.82 * u^1.15. Exponents above 0.5 tilt the surface density
toward the centre — 0.5 would be a flat, evenly covered disc.
- Logarithmic arms: the centreline of arm k at radius rn is
k * (2π / arms) + winding * 2π * ln(rn / 0.18) / ln(1 / 0.18)
i.e. θ grows with ln r, which is r = r0 · e^(bθ), the constant-pitch shape
real arms have. Pitch angle = atan(ln(1/0.18) / (winding · 2π)): 17° at the
default winding, 26° at 0.55, 9° at 1.8.
- Bulge membership, radius, arm index, angular offset, dot size and brightness
all come from an integer hash of (starId, salt), where starId mixes the seed
with the star's index. Addressing stars by index instead of pulling from a
PRNG stream means growing the pool after a resize never reshuffles the stars
already on screen. Math.random() is never called — not during render
(purity/SSR) and not in the loop (screenshots must be reproducible). Same
seed, same galaxy.
Behavior
- DOM: root div "relative isolate overflow-hidden" holding (a) a canvas that is
aria-hidden, pointer-events-none, absolute inset-0 and size-full — the
size-full matters, an absolutely positioned replaced element with inset-0
alone renders at its intrinsic 300x150 — and (b) a "relative z-10" wrapper for
children, so content always sits above the field and the canvas can never
intercept a click. The component paints NO background of its own: the surface
belongs to the consumer.
- Differential rotation: ω(rn) = 0.12 · (1 - shear + shear · 0.22 / (rn + 0.22))
rad/s, before `speed`. The 0.22 is a softening radius; a real flat rotation
curve is v/r, which spins to infinity at the centre and aliases into noise. At
shear 0.5 the centre leads the rim by 1.7x, at shear 1 by 5.5x, at 0 not at all.
- A star's angle is θ = θ0 + ω · elapsed, RECOMPUTED from elapsed each frame
rather than integrated. No rounding error accumulates, a paused loop resumes
exactly, and `elapsed` can live in a ref across effect restarts — so changing
tone or count at runtime does not rewind the disc to its starting angle.
- The arms are a PATTERN, not a fixed set of stars. It rotates at ω(0.6), the
disc's own rate at the corotation radius: inside it stars overtake the arms,
outside they fall behind, so crossings happen in both directions.
- Arm brightness: d = (θ - armCentreline - patternAngle) wrapped into one arm
spacing, u = |d| / (halfSpacing · scatter); skip the star when u ≥ 1,
otherwise multiply its alpha by (1 - u²)². Angular DENSITY is uniform — the
spiral is made of brightness alone. That is deliberate: seeding stars into the
arms looks right for one frame and then drifts, because every star at a given
radius shears at the same rate, so the density clump travels while the lit band
does not.
- The winding problem, solved: differential rotation smears any real arm pattern
into mush within minutes. Here the offset is wrapped by exactly one arm
spacing, and the pattern is arms-fold symmetric, so a wrapped star lands on an
identical point of the neighbouring arm. The jump is invisible because the
profile above is already zero at the wrap point — a star always crosses the
boundary dark. The result is genuine differential motion with a pattern that
is still the same shape an hour later.
- Radial ramps: alpha × (1 - 0.55 · rn) and dot radius × (1 - 0.28 · rn), so
brightness and size both fall off with distance from the core.
- Core: `core` is both the bulge share and the glow alpha (capped at 0.22). The
glow is two CACHED radial gradients — a nucleus at 1.05x the bulge radius and
a halo at 2.6x, each ink → transparent — painted inside translate(centre) +
scale(1, squash), so the bulge is projected exactly like the disc instead of
being a circle pasted onto an ellipse. Canvas interpolates gradient stops
premultiplied, so fading to `transparent` never drags a grey cast through the
midpoint. Rebuild them on resize and on a theme flip, never per frame, and
build them inside a try/catch: unlike fillStyle, which ignores a colour it
cannot parse, addColorStop throws — an environment that hands back an
unresolved var() must lose the glow, not the whole field.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
padding would offset the box); its first callback is the initial sizing. Try
observe(canvas, {box: "device-pixel-content-box"}) inside a try/catch —
browsers that do not know that box throw a WebIDL TypeError from observe()
rather than ignoring it — and fall back to observe(canvas). devicePixelRatio
(capped at 2) is the AUTHORITY on scale; the device-pixel box is trusted only
when it agrees to within 0.01, purely to absorb sub-pixel rounding at 1.25x
and 1.5x. Emulated and remoted surfaces report a 1:1 box while the page renders
at 2x, and following them there ships a visibly blurry canvas. Re-apply
ctx.setTransform after every resize (writing canvas.width resets the context)
and derive the scale from the actual backing size.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and speed
is non-zero. dt is clamped to 1/30s so a backgrounded tab cannot teleport the
disc on resume, and the time base resets when the loop restarts.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads the
ink, rebuilds the gradients from it, and repaints the still frame when the loop
is paused, so a frozen disc never keeps the old theme's colour.
- prefers-reduced-motion: reduce — read via useSyncExternalStore (server snapshot
false, so it is hydration-safe) and keep it in the effect deps. Under reduce
the loop never starts and exactly one frame is painted: arms, bulge and glow
are all present at elapsed 0, so the still frame is a complete galaxy rather
than a blank box.
- Cleanup on unmount: cancelAnimationFrame, the ResizeObserver, the
IntersectionObserver, the MutationObserver and the visibilitychange listener.
- Allocation: build the pool on retarget and mutate it in place. Per frame there
is one clearRect, at most two gradient fills, and one arc fill per LIT star —
unlit stars are skipped before any trig or path work. Nothing is allocated
inside the loop.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground),
var(--primary), var(--muted-foreground) or var(--chart-1), written onto the
canvas as an inline `color`, read back with getComputedStyle and handed to
fillStyle verbatim. Alpha rides on globalAlpha, never in the colour string, so
any syntax the browser resolves — oklch(), color-mix(), a rebranded token —
works with no parsing. Light and dark come for free: dark ink on a light
surface, light ink on a dark one.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
decoration with no pointer handling at all. Children stay fully interactive,
selectable and above the field; nothing here is focusable and nothing traps
scroll.
- Cost, honestly: at the defaults a 1440x600 hero draws 420 stars, of which
roughly 270 are lit in any given frame, plus two gradient fills — well under a
millisecond of main-thread work. Cost is linear in the effective star count;
the area cap and the 1400-star ceiling are the two safety valves, and the loop
is dead while the section is off screen or the tab is hidden.
Customization levers
- Shape: `arms` and `winding` are the silhouette. 2 arms with winding 0.55 is a
grand-design spiral; 6 arms with winding 1.8 is flocculent texture. `tilt` is a
pure projection and therefore safe to animate — 0 shows the spiral face-on, 80
gives an edge-on band.
- Motion: `speed` scales and reverses everything; `shear` decides whether the
disc turns as one piece (0) or streams through the arms (1). Move COROTATION
outward so most of the disc overtakes the pattern, inward so most of it lags.
BASE_OMEGA is the absolute rate — 0.12 rad/s is about one turn per minute.
- Density and cost: `count` is the request, `density` (px² per star) is the area
cap. Lower `scatter` for razor-thin arms and raise `count` to compensate, since
roughly `scatter` of the disc population is lit at any moment.
- Core: `core` drives the bulge share and the glow together. 0 leaves bare arms;
raise NUCLEUS_R / GLOW_R for a larger, softer bulge, or GLOW_MAX for a hotter
one.
- Palette: add a tone entry pointing at any token — var(--primary-foreground) is
the right ink for an inverted panel, var(--chart-3) for a branded field.
- Framing: DISC_FILL is how much of the box the disc claims, SQUASH_FIT_MIN
bounds how wide a near edge-on disc may get. For an off-centre galaxy, draw
around a centre fraction instead of width/2, height/2 — one substitution in
draw() and drawGlow(), and no other part of the model cares.Concepts
- Logarithmic arms — an arm's angle grows with the logarithm of its radius, which is the constant-pitch curve real spiral galaxies have.
winding(turns from bulge to rim) sets that pitch: 0.55 turns is a wide-open grand design at 26°, 1.8 turns is a tight flocculent texture at 9°. - Differential rotation — angular velocity falls with radius, so the inner disc visibly leads the rim.
shearis the whole spread: 0 turns the disc as one rigid piece, 1 makes the centre go five and a half times faster than the edge. The rate is softened near the middle because a truev/rcurve spins to infinity there. - Pattern with a corotation radius — the arms are not a set of stars, they are a pattern that rotates at the disc's own rate at 0.6 of the radius. Inside that circle stars overtake the arms, outside they fall behind, so you see stars streaming through the bands in both directions.
- Brightness is the spiral — stars are spread uniformly in angle; only their brightness follows the arm profile, peaking on the centreline and reaching zero at the edge of the lit band. Seeding stars into the arms instead would look right for one frame and then drift off the pattern, because every star at one radius shears at the same rate.
- Wrap at the arm spacing — a star's offset from its arm is wrapped by exactly one spacing, which lands it on an identical point of the next arm. Since the profile is already zero there, the jump is never seen — and that is what lets the disc shear forever without the arms winding up into mush.
- Normalised radii — every star's distance is stored as a fraction of the disc radius, so a resize (or a sidebar opening) recomputes one number and touches no star at all. Combined with the pause on off-screen and hidden tabs, and the single still frame under reduced motion, the field costs nothing when nobody is watching.
Prism
One beam split at an implied prism edge into a slowly drifting fan of token-coloured bands — additive light on dark surfaces, subtractive ink on light ones.
Orbits
Concentric orbital paths with bodies at their own periods — fading arc trails, a chord whenever two orbits line up, and radii and periods as props so the field can encode real values.