Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/** Which semantic token the stars and the core glow are painted with. */
export type GalaxyTone = "foreground" | "primary" | "muted" | "chart"

/**
 * Ink per tone — semantic tokens only. The value is written onto the canvas as
 * an inline `color`, then the *computed* string is read back and handed to the
 * 2D context verbatim. Any syntax the browser resolves (oklch(), color-mix(),
 * a brand colour the consumer put behind the token) works, and nothing here
 * ever parses or hard-codes a colour.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/galaxy.json

Prompt

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. shear is 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 true v/r curve 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.

On This Page