Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/** `drift` translates the whole sky sideways; `warp` accelerates it radially outward. */
export type StarfieldDirection = "drift" | "warp"

export type StarfieldTone = "foreground" | "primary" | "muted"

/**
 * Star 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

Installation

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

Prompt

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 same d feeds 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 same seed always paints the same sky. Math.random() is never called.
  • Radial warp with a constant termv(r) = rate · (0.35·maxR + r) instead of pure rate·r. Pure exponential motion has a 1/r steady 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 .dark class, so an inverted panel in a light theme still gets a real starfield.
  • devicePixelRatio as the authoritydevice-pixel-content-box is used only to absorb sub-pixel rounding when it agrees with devicePixelRatio; emulated surfaces that report 1:1 while rendering at 2× would otherwise ship a blurry canvas.
  • Reduced-motion still frame — under prefers-reduced-motion: reduce the 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.

On This Page