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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/vortex.jsonPrompt
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 falls —
dtheta/dt = swirl · maxR / (r + soft)againstdr/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; thesoftterm caps that rise so the centre never aliases into noise, and flattens a1/rdensity 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, sotrail={0.4}costs exactly whattrail={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
nheadings 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(orspeed={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.