Backgrounds

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.

Preview in your theme

Loading preview…

"use client"

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

/** Which semantic token the field is painted with. */
export type VortexTone = "primary" | "foreground" | "muted" | "chart"

/**
 * Particle ink per tone — semantic tokens only. The value is written onto the
 * canvas as an inline `color`, then the *computed* string is read back and
 * handed to the 2D context verbatim. Any syntax the browser resolves
 * (oklch(), color-mix(), a brand colour the consumer put behind the token)
 * works, and nothing here ever parses or hard-codes a colour.

Installation

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

Prompt

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

Build a React + TypeScript + Tailwind "Vortex" component — a canvas particle
field that spirals into a single point, used as a hero / auth / launch
backdrop. Its only dependency is a cn() class merger (clsx + tailwind-merge).
No particle library.

Contract
- export function Vortex(props): props extend React.ComponentProps<"div">
  (rest props spread onto the root) plus:
  - count?: number (default 220) — requested particles. The effective count is
    min(count, floor(area / density), 900): an area cap so a small card is not
    carpeted, and a hard ceiling so a typo cannot freeze the tab.
  - density?: number (default 1100, min 120) — CSS px^2 of canvas required per
    particle. Lower = denser.
  - speed?: number (default 1) — multiplier on the whole simulation, trail
    decay included; 0 freezes the field to the still frame and the loop never
    starts at all.
  - swirl?: number (default 1, clamped to -4..4) — angular rate multiplier.
    Negative reverses the spin; 0 leaves a straight radial fall.
  - inflow?: number (default 1, clamped to 0..4) — radial pull. 0 leaves
    differential rotation with no drain.
  - trail?: number (default 0.11, clamped to 0..0.45) — tail lifetime in
    SIMULATED seconds; 0 clears the buffer every frame instead of fading it.
  - arms?: number (default 0, clamped to 0..12) — 0 emits all around the rim,
    n snaps emission to n spiral arms.
  - tone?: "primary" | "foreground" | "muted" | "chart" (default "primary") —
    which semantic token the particles are painted with.
  - center?: [x, y] (default [0.5, 0.5], clamped to -0.5..1.5) — the eye, as a
    fraction of the box.
  - seed?: number (default 11) — integer seed for the field.
  - children render above the canvas; className merges onto the root.
- "use client": canvas, rAF and observers.
- Clamp every numeric prop up front and treat non-finite values as the
  default: a NaN count empties the field, count 1e6 freezes the tab, and a
  negative inflow runs the vortex backwards and strands every particle
  outside the box.

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 always sits above the field and the canvas can
  never intercept a click. The component paints NO background of its own: the
  surface belongs to the consumer.
- Geometry: the eye is center * box. maxR is the distance from the eye to the
  FARTHEST corner, so an off-centre eye still emits outside every edge. Two
  derived radii: soft = 0.16 * maxR and core = 0.02 * maxR. All of it is
  recomputed each frame from a ref rather than from effect deps, so a consumer
  animating `center` moves the field instead of reseeding it and wiping the
  trail buffer.
- Motion, per particle, in polar coordinates around the eye:
    dr/dt    = -inflow * (r + soft)
    dtheta/dt =  swirl * maxR / (r + soft)
  Integrate r EXACTLY over the step — r' = (r + soft) * exp(-inflow * ds) -
  soft — so the field advances identically at 30fps and at 144fps. Advance
  theta with the MID-STEP radius: the error stays under a degree at the 1/30s
  dt clamp, and unlike the closed form it survives inflow = 0.
  The `soft` term earns its place twice: a pure 1/r vortex spins to infinity
  at the eye and aliases into noise, and its 1/r steady-state density piles
  most of the field into the innermost tenth, which reads as an empty
  container. Total winding from rim to eye is
  swirl * maxR / inflow * (1/(core+soft) - 1/(maxR+soft)) — about 2.5 turns at
  the defaults, most of them in the inner third.
- Recycling: a particle at r <= core is re-emitted at 0.94..1.0 * maxR with a
  fresh heading hashed from its own cycle counter (deterministic, no
  Math.random). Brightness fades in over the outer 14% of the radius and out
  over the 8% around the eye, so nothing pops in at a corner or blinks out on
  a pixel. The particle is also flagged "fresh" so no segment is stroked from
  the eye back to the rim.
- Seeding: the radius is drawn from the steady state of the radial ODE
  (inverse CDF r = soft * ((maxR + soft) / soft)^u - soft), and in arms mode
  the angle comes from the closed-form spiral
  theta(r) = swirl * maxR / inflow * 1/(r + soft) + C, offset by the arm phase
  at the back-computed emission time t = ln((maxR+soft)/(r+soft)) / inflow.
  The field therefore looks like it has been running forever on frame 0 —
  which matters, because the reduced-motion still frame IS frame 0.
- Arms: emission angles quantise to `arms` headings with +-0.2 rad of jitter.
  Particles emitted seconds apart follow the same trajectory, so an arm draws
  itself as a curve without any per-arm bookkeeping; the whole pattern
  precesses at 0.06 rad/s * swirl so it reads as a band rather than a decal.
- Trails without history: each frame fades the WHOLE buffer with
  globalCompositeOperation "destination-out" (it scales destination alpha
  only, so the host surface shows through untouched and no background colour
  has to be guessed), then each particle strokes ONE segment from its previous
  position to its current one. That single point is the only history kept —
  tail length costs no memory and no path walk. Below a 1.2px step the
  particle is filled as a dot instead, which is what stops the crowded centre
  from turning into a scribble.
- The 8-bit fade floor (the trap in this effect): canvas alpha is 8-bit and
  destination-out is a multiply, so a browser that rounds stalls the fade at
  roughly 0.5/alpha. A 3%-per-frame fade never gets a pixel below ~17/255 and
  leaves a permanent veil over everything the field has touched. Fix it by
  accumulating the fade exponent (ds / trail) as a debt and spending it only
  once it is worth at least 0.14 alpha: the floor drops to ~4/255, below the
  perceptual floor on any real surface, and long trails simply fade in a few
  larger steps. trail = 0 skips all of it and clears the buffer.
- Ink: the canvas carries the tone token as an inline `color`; the loop reads
  getComputedStyle(canvas).color back and hands the string straight to
  fillStyle/strokeStyle, with per-particle alpha on globalAlpha. Never
  hand-parse a colour — passing the computed string through means oklch(),
  color-mix() and any rebranded token all work. Even the fade fill uses that
  string, because destination-out reads only the source alpha; the file needs
  no colour literal anywhere.
- Sizing: a ResizeObserver observes the canvas itself (not the root, whose
  padding would offset the box); its first callback is the initial sizing.
  Return early when the reported box is unchanged — writing canvas.width
  clears the trail buffer, and a resize that did not happen must not. Then set
  the backing store to cssSize * min(devicePixelRatio, 2) — capping at 2 keeps
  3x phones from tripling fill cost for no visible gain on 1-2px dots —
  re-apply ctx.setTransform(dpr, 0, 0, dpr, 0, 0) because resizing resets the
  context, rescale existing radii by maxR'/maxR instead of reseeding, and mark
  every particle fresh so no segment is stroked across the jump.
- Pool discipline: particles are objects created once by retarget() and
  recycled in place; growing the pool appends, shrinking truncates. Nothing is
  allocated inside the frame — at full-screen size a per-frame allocation is
  the failure mode, not the maths.
- Power: the rAF loop runs only while 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
  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 ink and repaints the still frame when the loop is paused. A running
  field needs no repaint — the trails already deposited fade out within one
  trail length.
- 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 the component paints ONE long
  exposure instead: each trajectory is integrated BACKWARDS for
  min(0.6, trail * 3.2) simulated seconds in 8 sub-steps and stroked as an arc
  that tapers to nothing, stopping early where the particle would have been
  outside the rim. speed = 0 paints the same frame. A still vortex — never a
  blank box, never a bare dot scatter.
- Interaction contract: none, deliberately. The canvas is decoration —
  aria-hidden, out of the accessibility tree, pointer-events-none, no focus
  stop, no keyboard surface, no scroll capture. Children stay fully
  interactive and selectable above it; anything clickable belongs in children
  and is wired by the consumer.
- Cleanup on unmount and on every dependency change: cancelAnimationFrame,
  ResizeObserver.disconnect, IntersectionObserver.disconnect,
  MutationObserver.disconnect, and remove the visibilitychange listener.

Rendering & styling
- Semantic tokens only, zero colour literals: var(--primary) /
  var(--foreground) / var(--muted-foreground) / 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, never in the colour
  string.
- Merge the consumer className via cn() on the root; the canvas keeps its own
  classes. Height comes from the consumer (h-56, min-h-svh, or just children).
- Cost, stated honestly: per frame the component does one full-canvas fill
  (the fade) plus one two-point path per particle. The fill scales with AREA,
  not with count, and at the default 220 particles the two are the same order
  of magnitude; the particles are what scales when you push count. The area
  cap, the 900 ceiling and the DPR cap are the three safety valves, and the
  whole thing costs exactly zero while off screen or in a hidden tab.

Customization levers
- Shape of the spiral: CORE_SOFTENING (0.16) is the single most expressive
  constant — lower it for a tight dramatic funnel with a dense bright eye,
  raise it for an even, lazy swirl. The swirl/inflow ratio is the winding:
  swirl 0.15 is a drain, swirl 2.5 is a hurricane, negative reverses it.
- Tail: `trail` is the length; STREAK_MIN (1.2px) decides how early dots
  become segments; the lineWidth factor (size * 1.25) is its weight;
  FADE_MIN_ALPHA (0.14) trades smoothness against the 8-bit veil — raise it if
  you see residue on a very dark surface, lower it for silkier long trails on
  a lighter one.
- Density and cost: `count` is the ceiling you ask for, `density` the px^2 each
  particle must be afforded — lower density to let a big count actually land
  on a small card. Drop MAX_DPR to 1 to halve fill cost on retina heroes.
- Palette: add a tone entry pointing at any token — var(--chart-3) for a
  branded field, var(--primary-foreground) for an inverted panel.
- Composition: `center` puts the eye behind a heading, a logo or a form; it
  travels through a ref, so you can animate it (pointer, scroll, focus) at 60
  fps without reseeding the field.
- Particle look: the draw step is one arc or one segment — swap in a sprite
  via drawImage, or scale size with (1 - t) instead of t to make particles
  swell as they fall in rather than shrink.
- Emission: `arms` plus ARM_JITTER and ARM_DRIFT is the hurricane/galaxy axis.
  arms 2 with a low swirl reads as a funnel, arms 6 as a pinwheel, arms 0 as
  weather.

Concepts

  • Angular velocity rises as the radius fallsdtheta/dt = swirl · maxR / (r + soft) against dr/dt = -inflow · (r + soft). A particle that starts as a slow drift from the corner ends up whipping around the eye, which is the whole reason a vortex reads as a vortex; the soft term caps that rise so the centre never aliases into noise, and flattens a 1/r density that would otherwise dump most of the field into the innermost tenth.
  • Trails by fading the buffer — no path is stored. Each frame fades the entire canvas with destination-out (alpha only, so the consumer's surface shows through) and strokes one segment from each particle's previous point to its current one. Tail length is a time constant, not an array, so trail={0.4} costs exactly what trail={0} costs in memory.
  • The 8-bit fade floor — a multiplicative fade in an 8-bit buffer stalls at roughly 0.5/alpha, so a 3%-per-frame fade leaves a permanent ~17/255 veil wherever particles have travelled. The fade is therefore accumulated as a debt and spent only when it is worth at least 0.14 alpha: the residue drops below perception and long trails pay with a few larger steps instead of a grey box.
  • Steady-state seeding — radii are sampled from the inverse CDF of the field's own steady state and, in arms mode, angles from the closed-form spiral plus the back-computed emission time. Frame 0 already looks like a vortex that has been running for a minute, which is what makes the reduced-motion still frame viable.
  • Arm-quantised emission — snapping emission to n headings costs two lines and no bookkeeping: particles released seconds apart travel the same trajectory, so the arm draws itself, and a slow precession keeps it from looking like a decal.
  • Long-exposure still frame — under prefers-reduced-motion: reduce (or speed={0}) the loop never starts; instead each trajectory is integrated backwards and stroked as a tapering arc, so motion-off users get a photograph of the vortex rather than a blank box or a scatter of unrelated dots.

On This Page