Plasma
The demoscene plasma field on one canvas — sines summed over x, y and time, mapped through a cyclic token palette at low resolution and upscaled to fill the container.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/plasma.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Plasma" component — the demoscene plasma
field painted on one canvas, used as a hero / card / auth backdrop. Its only
dependency is a cn() class merger (clsx + tailwind-merge).
Contract
- export function Plasma(props): props extend React.ComponentProps<"div">
(rest props + ref spread onto the root div) plus:
- variant?: "classic" | "ripple" | "marble" (default "classic") — which
sum-of-sines recipe to evaluate.
- frequency?: number (default 2.2) — cycles of the base wave across the
container's SHORTER side. Clamped 0.2..12. Pattern density, not cost.
- speed?: number (default 1) — multiplier on simulated time. 0 freezes the
field and the rAF loop never starts at all.
- intensity?: "subtle" | "medium" | "bold" (default "medium") — composite
opacity of the whole layer: 0.28 / 0.5 / 0.75.
- colors?: string[] (default ["chart-1", "chart-3", "chart-5"]) — theme token
names WITHOUT the leading "--", spread evenly around a cyclic palette.
- cellSize?: number (default 8) — CSS px per computed cell. The cost knob,
clamped 2..32 and raised further by the cell budget below.
- phase?: number (default 0) — simulated seconds the field starts at.
- children render above the canvas; className merges onto the root.
- "use client": canvas, rAF, three observers.
- Clamp every numeric prop up front and treat non-finite values as the default:
a NaN frequency paints one flat colour, a negative speed runs the field
backwards through the same states, cellSize 0 asks for one cell per pixel.
- There is no pointer, keyboard or focus surface: the component is decoration
and exposes no operable control, so there is no keyboard map to document.
Everything interactive belongs to children, which sit above the canvas.
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 is always above the field and the canvas can never
intercept a click or take a Tab stop. The component paints NO background of
its own: the surface belongs to the consumer, and the field composites over it.
- Coordinates: one field unit is the container's SHORTER side (x runs
0..w/min, y runs 0..h/min), so the pattern keeps its scale instead of
stretching when the box changes aspect ratio. k = 2*PI*frequency, and every
wave is evaluated at cell centres, (i + 0.5) / cols.
- classic — four waves summed and divided by 4 into [-1, 1]:
sin(x*k*1.00 + t*0.90)
+ sin(y*k*0.85 - t*0.70)
+ sin((x + y)*k*0.62 + t*0.45)
+ sin(dist(p, A)*k*1.15 - t*0.85)
Keep the four rates non-commensurate. Rates that share a common multiple make
the whole composition visibly repeat every few seconds, which is exactly how
a plasma stops looking alive.
- A is a radial source drifting on its own slow ellipse (0.28w by 0.24h, at
0.31 and 0.26 rad/s). A source pinned at the centre reads as a bullseye the
moment the eye finds it.
- ripple — the axis-aligned pair is replaced by a SECOND drifting source:
sin(dist(p,A)*k*1.35 - t*1.15) + sin(dist(p,B)*k*1.05 + t*0.85) + the same
diagonal term, divided by 3. Two ring systems interfering.
- marble — the classic sum (which spans [-4, 4]) folded through one more sine,
sin(sum*1.1 + t*0.25), so the palette is traversed several times and the
bands tighten into veins.
- Separability is the whole performance story. Fill per-column and per-row
tables once per frame — O(cols + rows) — and read them O(cols*rows) times.
The axis waves are one sine per column and per row; the diagonal wave is
expanded as sin(a + b) = sin a cos b + cos a sin b, so it costs two multiplies
per cell instead of a sine. What is left per cell is one sqrt and one sine
(two of each in ripple, plus one more sine in marble) and a palette lookup.
- Palette as a 256-entry LUT: lay the resolved stops evenly across a 256x1
canvas gradient with the FIRST stop repeated at offset 1, fill it, and read it
back with ONE getImageData. Repeating the first stop is what makes the ramp
cyclic; the browser's own rasteriser does the interpolation. Rebuild it only
when the resolved stop strings change — never per frame.
- Index the ramp with idx = (((v * 128) | 0) + 128) & 255. A mask, not a clamp:
the two endpoints are the same colour, so a value that runs past +/-1
continues into the next band instead of flattening into a plateau — which is
what lets you add gain for extra banding without introducing dead zones.
- Buffer discipline: keep one ImageData of cols x rows and rewrite RGB only.
Set the alpha bytes to 255 once at allocation and never touch them again;
the layer's transparency rides on globalAlpha at composite time. The
ImageData and the six Float32Array tables per axis are allocated once per
cell grid — allocating per frame at full-screen size is the failure mode.
putImageData into an offscreen canvas of exactly cols x rows, then drawImage
it stretched over the CSS box with imageSmoothingEnabled: the browser's
bilinear upscale is what puts the smoothness back, for the price of one blit.
- Cost, stated honestly: cells = ceil(w / cell) * ceil(h / cell), where
cell = max(cellSize, sqrt(w * h / 30000)). The 30k ceiling is the only thing
between cellSize={2} on a full-screen hero and a frozen tab, and it makes the
cost bounded by the container instead of by the prop — a 420x200 card honours
8px cells (~1.3k evaluations), a 1200x600 hero is coarsened to ~4.9px cells
and stops at 30k. The grid is measured in CSS px, so devicePixelRatio changes
the blit only, never the number of cells evaluated.
- Colour comes off the element: read each token with
getComputedStyle(canvas).getPropertyValue("--" + name) and hand the string to
the canvas verbatim. Never parse it — that is what makes oklch(),
color-mix() and a brand colour sitting behind the token all work. A string
the canvas cannot parse leaves fillStyle untouched, so probe it: assign
"transparent" first, assign the candidate, and compare the readback to
"rgba(0, 0, 0, 0)". An entry that does not resolve falls back to the
element's own `color` rather than painting confident black. If every stop
ends up identical (one token, or a total fallback) the field would be a flat
fill, so anchor the ramp on --background and let it read as a wash.
- getImageData can be refused by hardened privacy modes. Catch it, leave the
ramp null, and paint nothing: the consumer's surface then shows through
unchanged, which is the right way for a decoration to fail.
- Sizing: a ResizeObserver observes the CANVAS (not the root, whose padding
would offset the box) and 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 the option — and fall back to observe(canvas). Take the
MAX of devicePixelRatio and the device box ratio, capped at 2: emulated
surfaces report the device box in CSS px while rendering at 2x, and real
hi-dpi windows have been measured reporting devicePixelRatio 1 with a
truthful device box. Re-apply ctx.setTransform and imageSmoothingEnabled after
every resize — writing canvas.width resets the context — and bail early when
the box and dpr are unchanged.
- Power: the rAF loop runs only when an IntersectionObserver says the canvas is
on screen, document.visibilityState is "visible", motion is allowed and
speed > 0. dt is clamped to 1/30s so a backgrounded tab cannot jump the field
on resume, and the time base resets when the loop restarts.
- Time is injected, never read: simulated seconds live in a ref (so a prop
change does not snap the field back) and the drawn instant is phase + elapsed.
Nothing calls Date.now() or performance.now() during render, so SSR and
hydration agree, and speed={0} with a phase is a reproducible still.
- Theme flips: a MutationObserver on <html> (class / style / data-theme)
rebuilds the palette and repaints the still frame when the loop is paused.
Custom properties are not animatable, so the new token values are readable
immediately — no settle delay is needed here, unlike a surface animated with
transition-colors.
- 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 and exactly ONE frame is painted: the entire
field at t = phase, every band and vein in place. Never a blank box.
- Cleanup on unmount: cancelAnimationFrame, both observers, the
MutationObserver, the visibilitychange listener, and set the offscreen buffer
and the palette strip to 0x0 — a detached canvas keeps its backing store.
Stop the loop first: a frame that reaches a 0x0 buffer throws
InvalidStateError from drawImage.
Rendering & styling
- Semantic tokens only, zero colour literals: the default palette is
var(--chart-1) / var(--chart-3) / var(--chart-5), the monochrome anchor is
var(--background), and the last-resort ink is the element's own `color`. All
of them are read off the element, so light/dark and any rebranded palette come
for free. Opacity lives on globalAlpha, never inside a colour string.
- The intensity tier is the readability contract: put text-foreground on
overlaid copy and keep long body text off the "bold" tier.
- Merge the consumer className via cn() on the root; the canvas keeps its own
classes so the call site can size, round or border the field freely.
- Accessibility: the canvas is aria-hidden and pointer-events-none, with no
tabindex and no pointer handlers; children stay fully interactive, selectable
and above the field.
Customization levers
- Palette: colors takes token names — ["primary", "accent"] for brand-tinted,
["chart-2"] alone for a monochrome wash anchored on the surface. Length is
free; the stops are spread evenly around the cycle.
- Loudness: the ALPHA map is the single source of intensity; re-tune
0.28/0.5/0.75 or add a tier (e.g. faint: 0.15) without touching anything else.
- Pattern: frequency is the density (0.7 gives a few huge lobes, 5+ reads as
interference), and the four wave-number/rate pairs are the character. Retune
them, or add a fifth term — anything separable stays free, anything with a
distance in it costs a sqrt per cell.
- New variants: swap the radial term for a hyperbolic one (sin(x*y*k)) for
saddle plasma, or drop the diagonal wave for a purely axis-aligned weave.
- Cost: cellSize is linear in cells and quadratic in the number you pick —
20px cells cost about a fortieth of 3px cells and, after the bilinear
upscale, look nearly identical below ~4 cycles of frequency. MAX_CELLS
(30000) is the safety valve; lower MAX_DPR to 1 to quarter the blit on retina
if you ship very large heroes.
- Marble: the fold factor (1.1) is how many times the palette is traversed —
higher means tighter veins, lower melts back into the classic look.
- Motion: the drift ellipse of the sources (0.28 / 0.24 of the box, 0.31 and
0.26 rad/s) decides how much the composition wanders; speed scales everything
at once and 0 pins it.
- Composite: the layer is a plain source-over blit, so setting
globalCompositeOperation to "overlay" or "soft-light" before drawImage turns
the field into a tint of the surface underneath instead of a wash over it.
- Scope: it is a wrapper, not a page effect — a card header, a sidebar panel or
a modal banner all work; the root's overflow-hidden and rounding clip it.Concepts
- Sum of sines — the field is one continuous function of
x,yandt: four waves added together and normalised, not a collection of objects. Nothing can be counted, nothing enters or leaves, and the only "position" that exists is the drifting centre of the radial term. - Non-commensurate rates — the four waves run at rates chosen not to share a common multiple. Rates that do share one make the whole composition loop visibly every few seconds, which is the difference between a plasma that breathes and a plasma that stutters.
- Separable tables — everything that depends on
xalone oryalone is precomputed once per frame into per-axis arrays, and the diagonal wave is expanded withsin(a + b) = sin a cos b + cos a sin b. What survives into the per-cell loop is one square root and one sine, which is why full-bleed stays affordable. - Low-resolution buffer, bilinear upscale — the field is evaluated on
cellSize-sized cells into anImageData, then stretched over the container in one blit. The browser's filtering restores the smooth gradient for free, so cost tracks the cell grid rather than the pixel count, anddevicePixelRatiochanges only the blit. - Cyclic token palette — the ramp is a 256-entry LUT rasterised from the theme tokens with the first stop repeated at the end, so index 255 meets index 0 and the field can wrap without a seam. Colours are read off the element and handed to the canvas unparsed, which is what makes
oklch(),color-mix()and dark mode work with no colour maths in the component. - Cell budget —
cellSizeis a request. A container that would exceed 30,000 cells per frame gets coarser cells instead of dropped frames, so a typo cannot cost more than a fixed ceiling, and the loop additionally stops whenever the field scrolls off screen or the tab is hidden. - Injected instant — nothing reads a clock during render; the drawn time is
phase + elapsed, withelapsedaccumulated in a ref inside the loop. SSR and hydration therefore agree,speed={0}plus aphaseis a reproducible still, and the reduced-motion frame is the whole field att = phaserather than a blank box.
Lightning
Branching bolts struck at irregular intervals on one canvas — fractal channels with a bright core, a stacked glow, a decaying afterimage and a flash that lifts the whole surface.
Smoke
Rising turbulent smoke on one canvas — three scales of a single baked fBm tile, warped per band and thinning with height.