Starfield
A layered parallax star field on one canvas — 2–4 depth layers drifting or warping, seeded so the same sky comes back every time.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/starfield.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Starfield" component — a layered
parallax star field painted on one canvas, used as a hero / auth / 404
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Starfield(props): props extend React.ComponentProps<"div">
(rest props spread onto the root) plus:
- layers?: number (default 3) — depth layers, clamped to 1..4 and rounded.
- density?: number (default 70) — stars PER LAYER. The effective per-layer
count is min(density, floor(domainArea / 700), floor(1200 / layers)):
an area cap so a small card is not carpeted, and a 1200-star total cap
so a typo'd density cannot freeze the tab.
- speed?: number (default 1) — multiplier on the whole simulation; 0 freezes
the sky (and, with interactive off, the rAF loop never starts at all).
- direction?: "drift" | "warp" (default "drift").
- interactive?: boolean (default false) — pointer parallax.
- twinkle?: boolean (default true) — per-star brightness oscillation.
- tone?: "foreground" | "primary" | "muted" (default "foreground") — which
semantic token the stars are painted with.
- seed?: number (default 7) — integer seed for the star map.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF, observers and pointer events.
- Clamp every numeric prop before use and treat non-finite values as the
default: NaN layers would produce an empty sky, a negative speed would run
the warp backwards and pile every star onto one pixel.
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.
- Depth model: layer i of n has depth d = (i + 1) / n, and every property is
a lerp along d — radius 0.7 to 2.0 CSS px, base alpha 0.30 to 0.95, drift
speed 3.5 to 16 px/s, warp rate 0.09 to 0.25 /s, parallax weight = d. That
single ramp is what makes near layers read as near.
- Deterministic star map: every position, radius, alpha, twinkle phase and
rate comes from an integer hash of (starId, salt), where starId mixes the
seed with the layer and the star's index. Address stars by index rather
than pulling from a PRNG stream: growing a layer after a resize then 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 sky.
- Seeding domain: when interactive, the domain is the container inflated by
the 26px parallax travel on every side, and stars are drawn at
position + offset*depth - margin. Sliding a layer therefore can never
expose an empty band at an edge.
- drift: every star translates along one fixed unit vector (leftward with a
slight downward bias) at its layer's speed, wrapping with a modulo — not a
single compare, so an absurd `speed` cannot fling a star outside the domain
and strand it there.
- warp: stars carry a polar (angle, radius). Radial speed is
rate * (0.35 * maxR + r) — a constant term PLUS a proportional one. Integrate
it exactly over the step, r = (r + base) * exp(rate * ds) - base, so the
field advances identically at 30fps and 144fps. The constant term matters:
with pure exponential motion the steady-state density is 1/r and ~70% of
the sky piles into the innermost tenth, which reads as an almost empty
container. Initial radii are sampled from 1/v(r), i.e. the steady state, so
the field looks settled on its first frame instead of starting as a ring.
A star past maxR is recycled near the centre with a fresh angle hashed from
its own cycle counter (still deterministic, no Math.random). Stars grow and
fade in with r; when the per-frame radial delta exceeds ~0.7px the dot is
stroked as a short streak from its previous position instead — that is what
makes high `speed` look like hyperspace and low speed stay a calm dot field.
- Pointer parallax (interactive): pointermove on the root writes the pointer
position, normalised to [-0.5, 0.5], into a ref — never state, a trackpad
emits far more than 60 events/s and children must not re-render for a
decoration. The loop eases the offset toward -pointer * 26px on REAL dt
(not dt*speed, so slowing the sky must not make the cursor feel laggy) and
multiplies it by each layer's depth. pointerleave clears the flag and the
offset eases back to centre. Under reduced motion the pointer is ignored
entirely.
- Surface-aware degradation: a starfield on a light page is the ugliest way
this component can fail. Walk up from the canvas to the first ancestor with
an opaque background, resolve that colour through a 1x1 scratch canvas
(fillRect + getImageData — no hand parsing, so oklch() / color-mix() work),
and compute its relative luminance. Below 0.35 it is deep space: full
alpha, plus a soft halo arc behind the biggest near-layer stars. Above it
the same field degrades to faint dust: alpha x0.55, radius x0.85, no halos.
This follows the SURFACE, not a theme class, so an inverted panel inside a
light theme still gets stars and a light panel inside a dark theme does not.
Fall back to the `dark` class only if getImageData is unavailable.
- Ink: the canvas carries the tone token as an inline `color`, and the loop
reads getComputedStyle(canvas).color back and assigns the string straight to
fillStyle/strokeStyle, with per-star alpha on globalAlpha. Never hand-parse
a colour; passing the computed string through means any syntax the browser
understands works.
- 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 only used
when it agrees with it to within 0.01, purely to absorb sub-pixel rounding
at 1.25x/1.5x. Emulated and remoted surfaces exist where that box still
reports 1:1 while the page renders at 2x, and trusting it 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. Existing stars are rescaled in place, never reseeded.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas
is on screen, document.visibilityState is "visible", motion is allowed, and
there is something to animate (speed > 0 or interactive). dt is clamped to
1/30s so a backgrounded tab cannot teleport the sky 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 and the surface, repaints the still frame when the loop is paused,
and schedules ONE more read ~400ms later — surfaces animated with
transition-colors report an intermediate colour for a few hundred ms, which
is long enough to latch the wrong deep/faint decision.
- 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: exactly one static frame is painted —
a full sky, with twinkle phases already spread out so the stars still vary
in brightness — and the pointer is ignored.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver, the settle timeout, and the visibilitychange listener.
Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
var(--primary) / var(--muted-foreground) resolved through the canvas's own
computed style, so light/dark and any rebranded palette come for free.
Alpha lives in globalAlpha, never in the colour string.
- 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; children stay fully interactive, selectable and above the field.
Customization levers
- Depth feel: the six ramp constants (FAR/NEAR radius, alpha, drift, warp
rate) are the whole look. Widen the radius or alpha gap for a more dramatic
sense of depth, narrow it for a flat dust field. Depth also drives parallax
weight, so the ramp and the parallax stay consistent for free.
- Cost: density is the cost knob and it is linear — one arc fill per star per
frame, plus one extra fill for the ~45% of near-layer stars that carry a
halo. MIN_AREA_PER_STAR (700) and MAX_TOTAL_STARS (1200) are the two safety
valves; lower MAX_DPR to 1 to halve fill cost on retina if you ship very
large heroes.
- Motion: DRIFT_X/DRIFT_Y is the drift heading (make it vertical for falling
snow-like stars). WARP_BASE trades tunnel drama (lower = denser centre) for
even coverage. The streak threshold decides how early dots become streaks.
- Parallax: PARALLAX_PX is the peak-to-peak travel of the nearest layer; the
easing rate (1 - exp(-dt * 6)) is how quickly the sky chases the cursor.
- Palette: add a tone entry pointing at any token — var(--primary-foreground)
is the right ink for an inverted panel, var(--chart-2) for a branded field.
- Degradation: SURFACE_DARK_MAX_LUMA, FAINT_ALPHA_SCALE and
FAINT_RADIUS_SCALE define the light-surface look; raise the alpha scale if
your light theme is greyer than white, or force one branch by replacing the
probe with a constant if you know your surface.Concepts
- Depth ramp — one number per layer,
d = (i+1)/layers, drives radius, brightness, speed and parallax weight together. Near layers are big, bright and fast; far layers are small, dim and slow. Because the samedfeeds the parallax weight, the layers never disagree about which one is closer. - Seeded star map — every star property is an integer hash of
(starId, salt)rather than a draw from a stream, so a star keeps its identity when the field grows after a resize, and the sameseedalways paints the same sky.Math.random()is never called. - Radial warp with a constant term —
v(r) = rate · (0.35·maxR + r)instead of purerate·r. Pure exponential motion has a1/rsteady state that dumps ~70% of the stars into the innermost tenth of the container; the constant term flattens that to a ~4× spread while still visibly accelerating outward. - Streak threshold — when a star's per-frame radial delta passes ~0.7px it is stroked from its previous position instead of filled as a dot. The same expression is sub-pixel near the vanishing point, so the centre stays a sparkle field and only the rim turns into hyperspace lines.
- Surface-luminance degradation — the component reads the first opaque background behind it through a 1×1 scratch canvas and picks bright cores + halos below 0.35 luminance, faint dust above. It follows the surface, not the
.darkclass, so an inverted panel in a light theme still gets a real starfield. - devicePixelRatio as the authority —
device-pixel-content-boxis used only to absorb sub-pixel rounding when it agrees withdevicePixelRatio; emulated surfaces that report 1:1 while rendering at 2× would otherwise ship a blurry canvas. - Reduced-motion still frame — under
prefers-reduced-motion: reducethe loop never starts and the pointer is ignored, but the single painted frame is a full sky with twinkle phases already spread apart, so it still reads as a star field rather than a blank box.
Topography
A seamlessly tiling contour-map backdrop traced from seeded hash noise — quiet relief lines for docs shells, auth screens and empty states.
Hex Grid
A seamlessly tiling honeycomb backdrop — outlined, tinted or vertex-dotted, with an optional edge fade and a few seeded cells breathing on CSS alone.