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…
- Registry dependencies
- utils, canvas-surface, theme-color, use-prefers-reduced-motion
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
- Shared primitive: use-prefers-reduced-motion (installed automatically via
registryDependencies) — `usePrefersReducedMotion()` is the reduced-motion
read that keeps the loop from starting and the pointer ignored. If
rebuilding without it: a useSyncExternalStore over
matchMedia("(prefers-reduced-motion: reduce)") with a "change" listener and a
server snapshot of false.
- 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.
- Shared primitives: canvas-surface and theme-color (installed automatically
via registryDependencies). If rebuilding without them: size the canvas from
a ResizeObserver (device-pixel-content-box when supported, else the content
box; positive sizes only), run the loop only while an IntersectionObserver
reports the canvas on screen and document.visibilityState is "visible"
(start active so the first resize can start it, then act only on changes),
and re-read the theme when the class, style or data-theme attribute of
<html> changes. For the surface probe: resolve a CSS colour by painting it
into a 1x1 offscreen 2D canvas and reading the pixel back, with fillStyle
reset to "transparent" before every assignment so an unparsable value reads
as alpha 0 instead of the previous colour, and re-read on theme flips via a
MutationObserver on the class, style and data-theme attributes of <html>.
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). Backing store
scale = min(2, max(devicePixelRatio, deviceBox / cssWidth)); use the device
box verbatim when it equals that scale within 0.01, which absorbs sub-pixel
rounding at 1.25x/1.5x. Take the MAX of the two APIs: emulated and remoted
surfaces report a 1:1 device box while the page renders at 2x, and real 2x
windows have been measured reporting devicePixelRatio 1 next to an honest 2x
box; either one alone ships a visibly blurry canvas in one of the two
environments. 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. - Larger of two pixel ratios — the backing store scale is
max(devicePixelRatio, device box / CSS width), capped at 2, because each API is wrong somewhere: emulated surfaces report a 1:1 device box while rendering at 2×, and some real 2× windows reportdevicePixelRatio1. When the device box equals that scale it is used verbatim, absorbing sub-pixel rounding at 1.25×/1.5×. - 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.