Smoke
Rising turbulent smoke on one canvas — three scales of a single baked fBm tile, warped per band and thinning with height.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/smoke.jsonPrompt
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
yand 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 singlesource-infill 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,
qualityis 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 atspeed={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.
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.
Vortex
A canvas vortex — particles spiral into one point, winding faster as they fall, trailing comet tails made by fading the buffer instead of storing a path.