Backgrounds

Nebula

A layered interstellar cloud on canvas — token-tinted noise octaves drifting at different rates under a sparse star layer, with depth from parallax.

Preview in your theme

Loading preview…

"use client"

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

/**
 * How the cloud layers meet the surface behind them.
 * - `glow` adds light (`lighter`) — correct on a dark surface.
 * - `ink` lays pigment down (`source-over`) — correct on a light one, where
 *   adding light to near-white is a no-op and the nebula would vanish.
 * - `auto` probes the surface luminance behind the canvas and picks.
 */
export type NebulaBlend = "auto" | "glow" | "ink"

Installation

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

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "Nebula" component — a layered
interstellar cloud painted on two stacked canvases, used as a hero / auth /
launch backdrop. Its only dependency is a cn() class merger (clsx +
tailwind-merge).

Contract
- export function Nebula(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - layers?: number (default 3) — cloud octaves, clamped to 1..4 and rounded.
  - density?: number (default 0.55) — cloud coverage, clamped to 0..1.
  - speed?: number (default 1) — multiplier on the whole simulation; 0 freezes
    it (and, with interactive off, the rAF loop never starts at all).
  - colors?: string[] (default ["chart-1", "chart-4", "chart-2"]) — theme token
    names WITHOUT the leading "--", cycled across the layers.
  - stars?: number (default 70) — the sparse layer above the clouds. The
    effective count is min(stars, floor(domainArea / 1400), 400); 0 removes the
    layer entirely.
  - interactive?: boolean (default false) — pointer parallax.
  - blend?: "auto" | "glow" | "ink" (default "auto") — how the clouds meet the
    surface behind them.
  - seed?: number (default 11) — integer seed for the cloud shapes and the star
    map. Same seed, same nebula.
  - children render above both canvases; className merges onto the root.
- "use client": canvas, rAF, observers and pointer events.
- Clamp every numeric prop up front and treat non-finite values as the default:
  NaN layers would produce an empty sky, density 4 pushes the whole smoothstep
  window below zero and welds the octaves into a solid fog wall, and a negative
  speed would run the entire simulation backwards.
- Derive the effect's palette dependency from colors.join(" "), not the array:
  an inline colors={[...]} is a fresh identity on every render and would re-run
  the whole noise rasterisation for an unchanged palette.

Behavior
- DOM: root div "relative isolate overflow-hidden" holding, in order, (a) the
  cloud canvas, (b) the star canvas, (c) a zero-size token probe span, and (d)
  a "relative z-10" wrapper for children. Both canvases are 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. The component paints NO background of its own: the surface
  belongs to the consumer. It is pure decoration — nothing is focusable, there
  is no keyboard interaction, nothing enters the accessibility tree, and the
  canvases can never intercept a click or trap scroll.
- Cloud tiles, rasterised once per settings change and never per frame. For
  each layer build ONE seamless 256x256 alpha mask:
  * Tileable value noise — an integer hash fills a period x period lattice and
    sampling wraps the lattice indices (x0 % period, (x0 + 1) % period), which
    is what makes the right edge interpolate back into column 0. Measured, the
    wrap step is ~0.003 against a ~0.04 largest interior step: below the
    quantisation of the 8-bit alpha channel, i.e. no visible seam.
  * fBm — 3 octaves at periods p, 2p, 4p with amplitudes 1, 0.5, 0.25,
    normalised by their sum. Every octave period is an integer multiple of the
    tile, so the sum tiles as well.
  * Contrast-normalise the tile (subtract min, divide by range) BEFORE
    thresholding. Interpolated value noise regresses to the mean as the lattice
    coarsens — measured, period 7 spans 0.11..0.91 while period 2 spans only
    0.23..0.63 — so one shared threshold would erase the coarse near layer,
    which is exactly the layer meant to carry the composition.
  * alpha = smoothstep(edge0, edge1, normalised), edge0 = 0.66 - 0.42*density,
    edge1 = edge0 + 0.26. That window is the difference between a cloud and a
    fog wall: below edge0 the tile is fully transparent, which is what keeps
    holes in the field. Leave RGB white — only alpha carries the cloud, so a
    theme flip re-colours without re-running this loop. Budget: ~8ms for three
    256px tiles.
- Tinting: fill a scratch tile with the resolved token colour, then
  globalCompositeOperation "destination-in" + drawImage(mask). The flat fill
  comes out shaped like the cloud in one blit instead of a per-pixel tint loop.
  Rebuild the CanvasPattern afterwards — a pattern snapshots its source — and
  again after a resize, since a canvas whose backing store was re-allocated is
  entitled to drop everything derived from it.
- Depth model: layer i of n has depth d = (i + 1) / n, and every property is a
  lerp along d — lattice period 7 down to 2 (near layers are COARSER, because
  closer things look bigger), drift 1.6 to 6.5 CSS px/s, layer alpha 0.22 to
  0.36 additive or 0.14 to 0.24 as pigment, parallax weight = d. Headings come
  from a fixed table, all broadly leftward but a few hundredths of a radian
  apart, so the layers slide across each other instead of moving as one rigid
  sheet. Each layer starts at a hash-derived offset; without that stagger every
  tile begins at the same origin and the first frame is one over-dense stack.
- Drawing a layer: a CanvasPattern is anchored to the current transform, so
  setTransform(1,0,0,1,ox,oy) scrolls the tiling and fillRect(-ox,-oy,w,h)
  shifts the rect back to still cover the buffer. Wrap ox/oy into a single tile
  with a positive modulo, so a long session cannot drift into float
  imprecision.
- Two canvases on purpose. The cloud buffer is sized at 0.5 CSS px per pixel
  and stretched back up by the browser: clouds carry no high-frequency detail,
  so the upscale is invisible while the per-frame fill area is 16x smaller than
  a DPR-2 backing store. The stars get their own canvas at devicePixelRatio
  (capped at 2) because 1px discs very much do notice. Per frame the cost is
  `layers` full-container pattern fills at quarter area, plus one arc per
  visible star — state it honestly, this is the knob that decides whether a
  full-bleed hero holds 60fps.
- Stars: position, radius, base alpha, twinkle phase and rate are all integer
  hashes of (seed, index, salt). Address stars by index instead of pulling from
  a PRNG stream, so growing the field after a resize never reshuffles the ones
  already on screen; Math.random() is never called — not in render (purity and
  SSR) and not in the loop (screenshots must be reproducible). Stars are the
  frontmost plane: 1.25x the parallax of the nearest cloud but only 0.55x its
  drift, because a star field that visibly races the clouds reads as a bug.
  Twinkle is 0.55 + 0.45*sin(t*rate + phase). Sparse means sparse — 1400 px2 of
  canvas per star, hard ceiling 400.
- 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 * 20px on REAL dt (not
  dt*speed, so slowing the nebula down 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. When interactive, the star seeding domain is the
  box inflated by the parallax travel on all sides, so sliding the star plane
  can never expose an empty band at an edge. Under reduced motion the pointer
  is ignored entirely.
- Surface-aware blend: adding light to a near-white page is a no-op, so an
  additive nebula on a light surface simply disappears. Walk up from the canvas
  to the first ancestor that paints an opaque background, resolve that colour
  through a 1x1 scratch canvas (fillRect + getImageData, no hand parsing, so
  oklch() and color-mix() both work) and compute its relative luminance. Under
  0.35 it is deep space: composite "lighter" at the additive alphas, and the
  ~14% of stars whose hash clears the halo threshold get one. Above it:
  composite "source-over" at
  the pigment alphas and scale star alpha to 0.45 — faint grain instead of
  invisible sparkle. This follows the SURFACE, not a theme class, so an
  inverted panel inside a light theme still glows and a light panel inside a
  dark theme still takes pigment. Fall back to the `dark` class only when
  getImageData is unavailable.
- Star ink follows the same measurement, and blend="glow" / "ink" pin only the
  composite branch — the probe still runs. Paint the stars with whichever of
  var(--foreground) / var(--background) sits further in luminance from the
  measured surface. Reaching for --foreground alone is right on an ordinary
  page and invisible on an inverted panel, where a dark card inside a light
  theme would get near-black stars — and an inverted panel is exactly where a
  nebula tends to be used.
- Colour: never hard-code one and never parse one. Write `var(--token)` into
  the inline `color` of the zero-size probe span and read the computed value
  back — the browser does the substitution, so any syntax it understands works,
  and an unknown token degrades to the inherited text colour instead of
  throwing. Alpha always rides on globalAlpha, never inside the colour string.
- Sizing: a ResizeObserver observes the star canvas (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. 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/1.5x, because emulated and
  remoted surfaces exist that report 1:1 while the page renders at 2x. Re-apply
  ctx.setTransform after every resize (writing canvas.width resets the
  context), rescale existing stars in place instead of reseeding, and rebuild
  the cloud patterns.
- 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 field on resume, and the time
  base resets whenever the loop restarts. A background that burns a core in a
  hidden tab is a defect, not a trade-off.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
  the tokens and the surface, re-tints ONLY if one of them actually moved (the
  same read runs on every scroll-in and tab focus, so the no-change case must
  cost no pixels), 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 glow/ink 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 frame is composed, with every
  layer at its staggered offset and every star at its own phase-derived
  brightness, and the pointer is ignored. A still nebula, never a blank box.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
  MutationObserver, the settle timeout and the visibilitychange listener.

Rendering & styling
- Semantic tokens only, zero colour literals: cloud ink is var(--chart-1) /
  var(--chart-4) / var(--chart-2) (or whatever `colors` names) and star ink is
  var(--foreground) or var(--background), all resolved through the probe span,
  so light/dark and any rebranded palette come for free.
- Merge the consumer className via cn() on the root; both canvases keep their
  own classes. Rounding plus overflow-hidden on the root clip the nebula to any
  shape, so it works in a card header or a sidebar, not only a page hero.
- Accessibility: both canvases are aria-hidden and pointer-events-none and the
  probe span is aria-hidden and zero-size; children stay fully interactive,
  selectable and above the field. Keep the alpha ceilings where they are —
  overlaid text has to stay readable in both colour schemes.

Customization levers
- Palette: `colors` takes token names, so ["primary", "chart-5"] brands it and
  ["chart-1"] alone makes it monochrome. Length is free; layers cycle through
  the array.
- Shape: FAR_PERIOD / NEAR_PERIOD is the whole silhouette. Widen the gap for a
  more obviously layered cloud, raise both for fine wisps, lower both for a few
  huge masses. OCTAVES (3) is detail per layer, and each extra octave is one
  more sample per pixel — about a third more mount time, zero per-frame cost.
- Coverage: the edge0/edge1 formula is the density curve — narrow the 0.26
  window for hard-edged nebulae, widen it for smoke. Nothing here changes the
  per-frame cost.
- Motion: the drift ramp (1.6..6.5 px/s) and the HEADINGS table. Spread the
  headings wider for a turbulent read, point them all the same way for a calm
  sheet, and remember `speed` scales the whole simulation at once.
- Depth: PARALLAX_PX is the nearest layer's travel, STAR_PARALLAX is how far
  the star plane leads it, and the easing rate (1 - exp(-dt * 6)) is how
  quickly the field chases the cursor.
- Cost: CLOUD_PX is the honest knob — it is quadratic, so 0.35 halves the fill
  again and still reads, 1.0 is crisp at four times the fill of the default.
  TILE trades mount time against how long
  it takes the eye to notice a repeat (256 buffer px = 512 CSS px per repeat).
  Layers are linear: one more layer is one more full-container fill per frame.
- Blend: SURFACE_DARK_MAX_LUMA moves the glow/ink boundary and the four alpha
  constants are the loudness of each branch. Pin `blend` when you already know
  your surface — for instance a hero that is always dark inside a light app.

Concepts

  • Seamless noise tile — each layer is one 256px tile of fBm value noise whose lattice indices wrap, so the right edge interpolates back into column 0 and a repeating fill has no seam. Measured, the wrap step is about 0.003 against a 0.04 largest interior step — smaller than one step of the 8-bit alpha channel. That is what buys a whole-hero cloud for one small rasterisation.
  • Per-tile contrast normalisation — interpolated value noise regresses to the mean as its lattice coarsens: the period-7 octave spans 0.11 to 0.91 while period-2 spans only 0.23 to 0.63. Normalising each tile to its own range before thresholding is what keeps the coarse near layer, the one carrying the composition, from quietly disappearing under a shared threshold.
  • Depth from differential motion — every layer gets its own drift rate, its own heading a few hundredths of a radian off the others, and a parallax weight equal to its depth. The separation is that motion, not blur; the star plane leads with 1.25x the parallax of the nearest cloud but only 0.55x its drift, so it reads as in front without racing.
  • Two canvases, two resolutions — clouds render into a half-CSS-pixel buffer that the browser stretches back up (invisible on low-frequency shapes, sixteen times less fill than a DPR-2 store), while the stars get a full device-pixel canvas because 1px discs go soft immediately. One rAF loop drives both.
  • Glow or ink — the component measures the first opaque background behind it through a 1x1 scratch canvas. Below 0.35 luminance the clouds add light and stars carry halos; above it they lay pigment down and the stars fade to grain, because adding light to a near-white page is a no-op and an additive nebula on a light surface simply vanishes. The same measurement picks the star ink — --foreground or --background, whichever is further from the surface — so an inverted panel gets stars it can actually show. It follows the surface, not the .dark class.
  • Reduced-motion still frame — under prefers-reduced-motion: reduce the loop never starts and the pointer is ignored, but the single composed frame has every layer at its own staggered offset and every star at its phase-derived brightness, so it still reads as a nebula rather than a blank panel.

On This Page