Nebula
A layered interstellar cloud on canvas — token-tinted noise octaves drifting at different rates under a sparse star layer, with depth from parallax.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/nebula.jsonPrompt
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 —
--foregroundor--background, whichever is further from the surface — so an inverted panel gets stars it can actually show. It follows the surface, not the.darkclass. - Reduced-motion still frame — under
prefers-reduced-motion: reducethe 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.
Sonar
A radar sweep on one canvas — a rotating wedge with a decaying tail over concentric range rings, and contacts that flare when the beam passes and fade until the next one.
Ink
Drops of ink opening into water on one canvas — ragged fronts that expand, thin and dissolve, with every overlap deepening like ink on paper.