← All particles
Bloom Open
Particles leave a centre together and decelerate into an even ring.
revealelegantpremium64 particles · light · canvas-2d · automatic · finite
Variant
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.
237 lines · react only
import { useEffect, useRef } from "react";
/**
* Vibary · Bloom Open
*
* Particles leave a centre together and decelerate into a ring, the way
* something opens rather than the way something explodes.
*
* The technique: the angles are stratified, not random. Each particle
* takes slot i of N around the circle and is jittered within that slot
* only. Uniformly random angles clump — with sixty particles you get
* three visible knots and two gaps, and the ring reads as torn. One
* line of arithmetic is the difference between a ring and a mess.
*
* Deceleration is carried by streak length: each particle is drawn as a
* short radial dash sized from its own radial speed, so the dashes
* shorten into dots as the ring sets. The eye reads that as arrival —
* a ring of constant-size dots that simply stops reads as a cut.
*
* 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 BloomOpenProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Particles in the ring. */
count?: number;
/** Particle color. */
color?: string;
/** Fires once the ring has faded. */
onOpened?: () => void;
};
type VariantConfig = {
/** Ring radius as a fraction of the box's half-size. */
radius: number;
/** Seconds from the centre to the ring. */
seconds: number;
/** Turns per second the whole ring drifts while it opens. */
spin: number;
/** Particle radius in px once it has settled. */
dot: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A small, unhurried opening — closer to a breath than an event.
subtle: { radius: 0.6, seconds: 1.25, spin: 0.02, dot: 1.6 },
// Reads as an opening at a glance. All-purpose.
default: { radius: 0.74, seconds: 1, spin: 0.035, dot: 1.9 },
// Wider and quicker, with a longer streak on the way out.
playful: { radius: 0.88, seconds: 0.8, spin: 0.06, dot: 2.2 },
};
type Mote = {
angle: number;
/** Fraction of the nominal ring radius this one settles at. */
reach: number;
/** Seconds before it leaves the centre. */
delay: number;
};
export default function BloomOpen({
variant = "default",
count = 64,
color = "#A8C6F0",
onOpened,
}: BloomOpenProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// The parent's callback is read through a ref, assigned in an effect
// rather than during render, so an inline arrow can't restart the ring.
const openedRef = useRef(onOpened);
useEffect(() => {
openedRef.current = onOpened;
});
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext("2d");
if (!context) return;
const config = VARIANTS[variant];
const reduced =
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let width = 0;
let height = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const 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);
};
resize();
const random = (min: number, max: number) => min + Math.random() * (max - min);
// Stratified: slot i of N, jittered inside its own slot. The jitter
// stays below half a slot so no two motes can trade places, which is
// what keeps the spacing even instead of merely random.
const motes: Mote[] = Array.from({ length: count }, (_, index) => ({
angle: ((index + random(-0.36, 0.36)) / count) * Math.PI * 2,
reach: random(0.9, 1.08),
delay: random(0, 0.12),
}));
const HOLD = 0.32;
const FADE = 0.55;
const total = config.seconds + HOLD + FADE;
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
const render = (elapsed: number) => {
context.clearRect(0, 0, width, height);
const centreX = width / 2;
const centreY = height / 2;
const ring = (Math.min(width, height) / 2) * config.radius;
const drift = elapsed * config.spin * Math.PI * 2;
// One soft flash where the motes came from, gone within a third of
// a second. Any longer and it becomes a second effect.
const flash = Math.max(0, 1 - elapsed / 0.34);
if (flash > 0) {
const glow = context.createRadialGradient(
centreX,
centreY,
0,
centreX,
centreY,
ring * 0.65
);
glow.addColorStop(0, color);
glow.addColorStop(1, "rgba(0,0,0,0)");
context.globalAlpha = flash * flash * 0.32;
context.fillStyle = glow;
context.fillRect(0, 0, width, height);
}
const fadeOut =
elapsed > config.seconds + HOLD
? Math.max(0, 1 - (elapsed - config.seconds - HOLD) / FADE)
: 1;
context.fillStyle = color;
context.strokeStyle = color;
context.lineCap = "round";
for (const mote of motes) {
const progress = Math.min(1, Math.max(0, (elapsed - mote.delay) / config.seconds));
const target = ring * mote.reach;
const radius = target * ease(progress);
// Radial speed, sampled rather than differentiated: the distance
// covered over the last sixteenth of the travel.
const trailing = target * ease(Math.max(0, progress - 0.06));
const streak = Math.min(radius - trailing, ring * 0.3);
const angle = mote.angle + drift;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const x = centreX + cos * radius;
const y = centreY + sin * radius;
context.globalAlpha = fadeOut * (0.35 + progress * 0.55);
if (streak > 0.5) {
context.lineWidth = config.dot * 1.5;
context.beginPath();
context.moveTo(centreX + cos * trailing, centreY + sin * trailing);
context.lineTo(x, y);
context.stroke();
}
context.beginPath();
context.arc(x, y, config.dot, 0, Math.PI * 2);
context.fill();
}
context.globalAlpha = 1;
};
// Reduced motion: the ring as it ends up. The formed shape is the
// message — an empty box would say nothing at all.
if (reduced) {
render(config.seconds + HOLD * 0.5);
const onResizeStill = () => {
resize();
render(config.seconds + HOLD * 0.5);
};
window.addEventListener("resize", onResizeStill);
return () => window.removeEventListener("resize", onResizeStill);
}
let frame = 0;
let elapsed = 0;
let last = performance.now();
const tick = (now: number) => {
const delta = Math.min((now - last) / 1000, 0.05);
last = now;
elapsed += delta;
render(elapsed);
if (elapsed >= total) {
context.clearRect(0, 0, width, height);
openedRef.current?.();
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
render(elapsed);
};
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" }}
/>
);
}About this effect
The moment something opens — a workspace unlocking, a feature becoming available, a panel that has finished preparing itself. The angles are stratified rather than random: each particle takes one slot of the circle and is jittered only inside that slot, because uniformly random angles clump into knots and gaps and the ring reads as torn. Deceleration is carried by streak length — each particle is a short radial dash sized from its own radial speed, so the dashes shorten into dots as the ring sets, and the eye reads arrival rather than a cut.
Unlocking a featurePanel openingSuccessful activationFocus on a new control