Pollen Swirl
Fine motes caught in a slow vortex, shearing themselves into spiral arms.
The canvas in this preview is the file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef } from "react";
/**
* Vibary · Pollen Swirl
*
* Fine motes caught in a slow vortex.
*
* The technique that makes it a vortex rather than a turntable: the
* motes do not share an angular speed. Inner motes turn faster than
* outer ones, so the field shears against itself and spiral arms appear
* on their own — no spiral is ever drawn, and no mote is ever told to
* follow one. Rotate everything at one rate instead and you get a rigid
* disc, which reads as a spinning image rather than as air moving.
*
* Supporting detail: differential rotation alone winds the arms tighter
* forever until they smear. A slow inward drift with re-seeding at the
* rim keeps arms continuously forming and dissolving, which is what a
* real eddy does.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `count`, `color`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type PollenSwirlProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Motes in the air. They are small; this can be generous. */
count?: number;
/** Mote colour. */
color?: string;
};
type VariantConfig = {
/** Multiplier applied to `count`. */
density: number;
/** Turns per second at the very centre. Every radius is slower. */
spin: number;
/** How far the vortex reaches, as a fraction of the surface. */
reach: number;
/** Inward drift in px per second. */
inflow: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost still air, with a few motes hanging in it.
subtle: { density: 0.65, spin: 0.05, reach: 0.62, inflow: 3 },
// A slow eddy. All-purpose.
default: { density: 1, spin: 0.08, reach: 0.7, inflow: 5 },
// A draught through the room: quicker, wider, more of it.
playful: { density: 1.35, spin: 0.13, reach: 0.78, inflow: 8 },
};
type Mote = {
/** Distance from the centre. Drives speed, opacity and lifetime. */
radius: number;
angle: number;
/** Per-mote speed variation, so the shear is not perfectly clean. */
drift: number;
size: number;
shimmer: number;
};
/** Seconds of simulation to run before the first painted frame. */
const PREROLL = 7;
export default function PollenSwirl({
variant = "default",
count = 160,
color = "#C39A3E",
}: PollenSwirlProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const total = Math.max(10, Math.round(count * config.density));
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
let ratio = 1;
let reach = 1;
let core = 1;
let motes: Mote[] = [];
const resize = () => {
const rect = canvas.getBoundingClientRect();
ratio = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.floor(width * ratio));
canvas.height = Math.max(1, Math.floor(height * ratio));
context.setTransform(ratio, 0, 0, ratio, 0, 0);
reach = Math.max(12, Math.max(width, height) * config.reach);
// Inside this radius the vortex turns nearly as a body; outside
// it, speed falls away with distance.
core = reach * 0.22;
};
const random = (min: number, max: number) => min + Math.random() * (max - min);
const seed = (mote: Mote, initial: boolean) => {
mote.radius = initial ? random(core * 0.4, reach) : random(reach * 0.86, reach * 1.05);
mote.angle = random(0, Math.PI * 2);
mote.drift = random(0.75, 1.3);
mote.size = random(0.5, 1.5);
mote.shimmer = random(0, Math.PI * 2);
};
const build = () => {
motes = Array.from({ length: total }, () => {
const mote: Mote = { radius: 0, angle: 0, drift: 1, size: 1, shimmer: 0 };
seed(mote, true);
return mote;
});
};
const advance = (delta: number) => {
for (const mote of motes) {
// Differential rotation: fast at the middle, slower further out.
// This one line is what produces the arms.
const rate = config.spin / (1 + mote.radius / core);
mote.angle += rate * mote.drift * Math.PI * 2 * delta;
mote.radius -= config.inflow * delta;
mote.shimmer += delta * 0.9;
if (mote.radius < core * 0.3) seed(mote, false);
}
};
const drawFrame = () => {
context.clearRect(0, 0, width, height);
const centreX = width / 2;
const centreY = height / 2;
context.fillStyle = color;
for (const mote of motes) {
// Fade in near the rim and out near the middle, so re-seeding is
// never something you can catch happening.
const near = Math.min(1, (mote.radius - core * 0.3) / (reach * 0.22));
const far = Math.min(1, (reach * 1.1 - mote.radius) / (reach * 0.24));
const alpha = Math.max(0, Math.min(near, far)) * (0.35 + 0.3 * Math.sin(mote.shimmer));
if (alpha <= 0.01) continue;
context.globalAlpha = Math.min(0.75, alpha + 0.25);
context.beginPath();
context.arc(
centreX + Math.cos(mote.angle) * mote.radius,
// Squashed, so the swirl sits as a plane seen at an angle.
centreY + Math.sin(mote.angle) * mote.radius * 0.78,
mote.size,
0,
Math.PI * 2
);
context.fill();
}
context.globalAlpha = 1;
};
const settle = () => {
// The arms are a product of time, so a fresh field has none. Run
// the simulation forward before the first paint and it opens with
// the structure already in it.
for (let step = 0; step < PREROLL * 30; step++) advance(1 / 30);
};
resize();
build();
settle();
// Reduced motion: the pre-rolled field, held. Because the still is
// taken after the shear has done its work, the spiral arms are the
// thing you see — the whole point of the effect survives.
if (reduced) {
drawFrame();
const onResizeStill = () => {
resize();
build();
settle();
drawFrame();
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
advance(delta);
drawFrame();
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
build();
settle();
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, count, color]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block", pointerEvents: "none" }}
/>
);
}About this effect
Air made visible, for a warm empty state, a sunlit hero or the pause between two steps. The motes do not share an angular speed: inner ones turn faster than outer ones, so the field shears against itself and spiral arms appear on their own — no spiral is ever drawn and no mote is told to follow one. Rotate everything at a single rate and you get a rigid disc, which reads as a spinning image rather than as air moving. Differential rotation alone would wind those arms tighter forever until they smear, so a slow inward drift with re-seeding at the rim keeps arms continuously forming and dissolving, and the field is run forward before the first paint so it opens with structure already in it.