Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/**
 * `classic` — the demoscene sum of sines: horizontal + vertical + diagonal +
 * one radial wave. `ripple` — two drifting radial sources interfering, so the
 * field reads as concentric rings crossing each other. `marble` — the classic
 * sum folded through one more sine, which tightens the bands into veins.
 */
export type PlasmaVariant = "classic" | "ripple" | "marble"

/** Composite opacity per tier — the single knob that keeps overlaid text readable. */

Installation

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

Prompt

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, y and t: 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 x alone or y alone is precomputed once per frame into per-axis arrays, and the diagonal wave is expanded with sin(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 an ImageData, 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, and devicePixelRatio changes 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 budgetcellSize is 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, with elapsed accumulated in a ref inside the loop. SSR and hydration therefore agree, speed={0} plus a phase is a reproducible still, and the reduced-motion frame is the whole field at t = phase rather than a blank box.

On This Page