Backgrounds

Smoke

Rising turbulent smoke on one canvas — three scales of a single baked fBm tile, warped per band and thinning with height.

Preview in your theme

Loading preview…

"use client"

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

/**
 * `plume` is a column rising out of one origin, `haze` a full-width bank of
 * ground smoke, `wisps` a sparse field of tendrils.
 */
export type SmokeVariant = "plume" | "haze" | "wisps"

export type SmokeTone = "foreground" | "muted" | "primary" | "chart"

/**

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "Smoke" component — rising turbulent
smoke painted on one canvas, used as a hero / empty-state / CTA backdrop. Its
only dependency is a cn() class merger (clsx + tailwind-merge).

Contract
- export function Smoke(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - variant?: "plume" | "haze" | "wisps" (default "plume") — one pipeline,
    three parameter sets: a column rising from an origin, a full-width bank of
    ground smoke, or a sparse tendril field.
  - density?: number (default 0.5) — thickness, clamped to 0..1. 1 is the
    design ceiling; do NOT offer more, a background's job is to stay under the
    text.
  - speed?: number (default 1) — multiplier on rise, drift and warp. 0 freezes
    the field AND the rAF loop never starts.
  - tone?: "foreground" | "muted" | "primary" | "chart" (default "muted") —
    which semantic token the smoke is painted with.
  - origin?: number (default 0.5) — horizontal position of the column, 0..1.
    PLUME ONLY; haze and wisps ignore it, and the JSDoc has to say so.
  - quality?: number (default 0.32) — field resolution as a fraction of the
    container, clamped to 0.1..1. The honest cost knob: cost is quadratic in it.
  - seed?: number (default 5) — integer seed for the noise tile.
  - children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop before use and treat non-finite values as the
  default. quality 12 would ask for a 20-megapixel buffer, a negative speed
  would drive the smoke back down into the floor, NaN would poison the
  transform and blank 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. The component paints NO background of its own: the surface
  belongs to the consumer. There is no keyboard surface and no pointer
  handling at all; it is decoration, children own every interaction.
- ONE baked tile, not per-pixel noise. On mount (and whenever seed or variant
  changes) bake a 192x192 seamless fBm tile into an offscreen canvas:
  - 4 tile octaves; octave k has (4 << k) lattice cells across the tile, and
    the lattice value comes from an integer hash of (seed + k*977, i, j).
    Lattice indices wrap modulo their own period — that is what makes the tile
    seamless, the right edge interpolates straight back into the left one.
  - Bilinear interpolation with smoothstep weights, amplitudes 1, 0.52, 0.27,
    0.14 normalised by their sum.
  - Normalise the result by MEAN and SD, not min/max: n = 0.5 + (v - mean) /
    (sd * 6), i.e. +/-3 SD spans the unit window. The contrast window then
    means the same thing whatever the octave mix does to the raw distribution.
  - alpha = smoothstep(clamp01((n - lo) / (hi - lo))) with the variant's
    window: plume 0.40..0.66, haze 0.33..0.70, wisps 0.52..0.68. Narrow window
    high up the distribution = mostly empty tile = tendrils; wide window low
    down = dense atmosphere. Write alpha into the alpha channel and leave RGB
    opaque white; the tint pass throws the colour away.
  Per-pixel noise per frame is the naive way to draw smoke and it is
  unaffordable at full-bleed size. Math.random() is never called — not during
  render (purity/SSR) and not in the loop (screenshots must be reproducible).
- Low-resolution field. Allocate a second offscreen canvas at
  round(cssSize * q) where q = min(quality, 560 / longestCssEdge) — one factor
  for both axes so the aspect ratio survives, and a cap so the simulation stops
  growing with the viewport — past that size only the final blit scales.
  Everything below is composited there and blitted up once with
  imageSmoothingEnabled. Smoke has no high-frequency detail to lose, so the
  upscale is free blur rather than lost quality.
- Three frame octaves, all sampling THAT ONE tile through a repeating pattern.
  Per octave o (0 = coarse): magnification scale[o] = (longestFieldEdge / 192)
  * [1.5, 0.78, 0.4][o]; rise[o] = riseRate * fieldHeight * (1 + 0.55o) px/s
  upward; a small alternating sideways drift; warp amplitude growing with o.
  Finer octaves rise faster and warp harder — that velocity gradient is what
  stops three copies of one tile from reading as one tile.
- Curl-like domain warp, per horizontal band. Split the field into bands of
  ~7px (max 32) and give each band a horizontal displacement
  D(y) = A*sin(y*fa + t*0.55 + phase) + B*sin(y*fb - t*0.9 + phase2), with
  fa = 2pi / (0.85 * fieldHeight), fb = 2pi / (0.32 * fieldHeight) and
  B = 0.3A. Crucially also apply the DERIVATIVE
  D'(y) = A*fa*cos(...) + B*fb*cos(...) as a SHEAR on the band, so adjacent
  bands agree at their shared edge to first order. A constant displacement per
  band stair-steps; the sheared version reads as one continuously curling
  sheet. Wavelengths track the field height so the curl looks the same on a
  banner and on a tall hero.
- Painting a band: keep the CTM as the map from pattern space to field space,
  [s, 0, D'*s, s, e, yo] with e = xo + D(yc) + D'*(yo - yc), so field pixel
  (X, Y) samples the tile at ((X - xo - D(Y))/s, (Y - yo)/s). fillRect then
  runs in pattern space — map the band rectangle back through the same matrix
  (the shear only tilts x, so the two y ends bound the whole span exactly) and
  add a 1-unit x margin, which the canvas edge clips.
- Band edges must be WHOLE field rows (round them). Fractional edges get
  anti-aliased, and two neighbouring 50%-covered edges composite to 75%, not
  100% — a faint horizontal line at every boundary that the upscale then
  magnifies.
- Density by height: the band's alpha is the octave weight times
  smoothstep(clamp01((y/fieldHeight - fadeLow) / (fadeHigh - fadeLow))) — 0 at
  the ceiling, 1 at the floor. plume 0..1 (a full S-curve), haze 0..0.5 (the
  bottom half stays solid, the top half fades), wisps 0.06..1. Folding the
  falloff into the per-band alpha means the whole vertical gradient costs
  nothing: no mask pass, no extra full-field fill.
- Tint and column mask in ONE pass: globalCompositeOperation "source-in" then
  fill the whole field. The fill style is the ink string for haze/wisps, and
  for a plume a horizontal gradient of the SAME ink — transparent at
  origin +/- 0.34, ink at origin +/- 0.14. source-in keeps the accumulated
  alpha and replaces the colour, so that gradient is simultaneously the tint
  and the column taper. Canvas gradients interpolate premultiplied, so fading
  to "transparent" does not drag the ink through black the way a CSS gradient
  would. Wrap addColorStop in try/catch and fall back to a flat tint.
- Ink: the canvas carries the tone token as an inline `color`; read
  getComputedStyle(canvas).color back and assign the string straight to
  fillStyle, with alpha on globalAlpha. Never hand-parse a colour; passing the
  computed string through means oklch(), color-mix() and any rebranded palette
  all work. Each tone carries a loudness trim (foreground 0.72, muted 1,
  primary 0.85, chart 0.9) because --foreground is near-black on light and
  near-white on dark, so the same alpha reads twice as loud with it.
- Long uptime: wrap the scroll offsets modulo one tile period each frame. The
  picture is identical and the numbers handed to setTransform stay small after
  hours on screen.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
  padding would offset the box); its first callback is the initial sizing.
  devicePixelRatio capped at 2 sizes the visible backing store; re-apply
  ctx.setTransform and imageSmoothingEnabled after every resize, because
  writing canvas.width resets the context. Recompute the field size, the band
  count, the octave constants and the tint gradient there too.
- 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 teleport the
  smoke on resume, and the time base resets when the loop restarts. Frozen and
  off-screen fields still hold a painted frame — pausing must never blank the
  canvas.
- Theme flips: a MutationObserver on <html> (class/style/data-theme) re-reads
  the ink, rebuilds the gradient carrying it, and repaints the still frame
  when the loop is paused.
- 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, at a
  non-zero phase (t = 12.5) so the three octaves are already displaced from
  each other instead of stacked in register. A full field of smoke, just a
  still one — never a blank box.
- Cleanup on unmount: cancelAnimationFrame, the ResizeObserver, the
  IntersectionObserver, the MutationObserver and the visibilitychange
  listener. Everything is rebuilt when a dependency changes, so the same
  teardown covers both paths.
- Nothing is allocated inside the frame loop: the tile, the pattern, the tint
  gradient and the per-octave constant arrays are built on mount and on
  resize. Per-frame allocation is the failure mode of a full-screen effect.

Rendering & styling
- Semantic tokens only, zero colour literals: the ink is var(--foreground) /
  var(--muted-foreground) / var(--primary) / var(--chart-1) resolved through
  the canvas's own computed style, so light/dark and any rebranded palette
  come for free. Alpha lives in globalAlpha and in the tile's alpha channel,
  never in the colour string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
  classes. Sizing, rounding and the surface come from the call site.
- Accessibility: the canvas is aria-hidden and pointer-events-none, pure
  decoration that never traps scroll, focus or clicks; children stay fully
  interactive above it. Overlaid copy should use text-foreground — the muted
  token has no contrast headroom left to spend on a wash.

Customization levers
- Shape: PROFILES is the whole vocabulary — contrast window (lo/hi), vertical
  window (fadeLow/fadeHigh), rise, sway and the three octave weights. A fourth
  variant is one more entry, not new code: e.g. "steam" = wisps' contrast with
  haze's rise; "fog" = fadeHigh 1.0 with sway near zero for a flat, still bank.
- Direction: rise[] is the only thing that says "up". Negate it for smoke
  sinking into a footer, or move the term into drift[] for a sideways
  slipstream; then flip the fade so density falls off away from the source.
- Cost: quality is quadratic and is the first knob to touch; the 560px
  long-edge cap is the safety valve; MAX_DPR 1 halves the blit on retina.
  Bands only cost draw calls, not fill area — raising them buys smoothness in
  the warp almost for free, lowering them below ~8 makes the shear visible.
- Turbulence: the two warp wavelengths (0.85 and 0.32 field heights) and the
  rates (0.55, 0.9 rad/s) set how curly and how fast the boiling is; equal
  rates make it read as one rigid wave, so keep them incommensurate.
- Palette: add a tone entry pointing at any token — var(--primary-foreground)
  is the right ink on an inverted panel, var(--chart-3) for a branded haze.
  Give every new tone a trim so density keeps meaning one thickness.
- Layering: it is a container, so nest it — a haze behind a hero and a plume
  inside one card, or a noise-grain layer on top of it for a grainier finish.

Concepts

  • One baked tile, three scales — the noise is evaluated once into a 192px seamless tile and then sampled as a repeating pattern at three magnifications. Per-pixel noise every frame is the naive way to draw smoke and it does not survive full-bleed sizing; this turns the frame into a handful of fills. The repeat stays illegible because the three copies rise at different speeds and are warped independently.
  • Curl-like domain warp — each horizontal band is displaced sideways by a sum of two out-of-phase sinusoids in y and sheared by that sum's derivative. The derivative is what makes neighbouring bands agree at their shared edge, so the field reads as one continuously curling sheet instead of a stack of sliding strips.
  • Height falloff as band alpha — density fades toward the ceiling by scaling each band's globalAlpha, not by compositing a mask. The entire vertical gradient therefore costs nothing extra, and the variant's fade window is a pair of numbers rather than a second render pass.
  • Tint and mask in one source-in — the accumulated field is alpha only; a single source-in fill replaces its colour with the theme ink, and when that fill is a horizontal gradient of the same ink, the plume's column taper comes along for free.
  • Low-res field, one upscale — the simulation runs at roughly a third of the container in each axis and is blitted up once. The upscale doubles as the blur that smoke wants, quality is a quadratic cost knob, and the 560px long-edge cap stops the simulation from growing with the viewport, so a full-bleed hero pays for the blit rather than for the physics.
  • Still frame at a non-zero phase — under prefers-reduced-motion, or at speed={0}, the loop never starts and the one painted frame is taken at t = 12.5 rather than t = 0, so the three octaves are already offset from each other and the result looks like mid-flight smoke instead of three stacked copies of the same tile.

On This Page