Glow Pop
One soft expansion of light for a small win, and nothing more.
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 · Glow Pop
*
* One soft expansion of light, and nothing else. For a small win — a
* task ticked off, a field accepted, a message sent.
*
* The technique: the gradient's stops move, the shape does not scale.
* The core empties as the radius grows, so a filled bloom becomes a soft
* ring and then nothing — light spreading outward rather than a circle
* being made bigger. A scaled sprite keeps its internal proportions and
* always reads as a scaled sprite; moving the stops is what makes it
* read as light.
*
* Everything else is restraint held on purpose. One pulse, never two —
* the variants change how far and how fast it travels and nothing about
* how many times. Peak opacity stays under half, the radius decelerates,
* and the whole thing is one gradient fill per frame.
*
* Self-contained: one canvas, no dependencies at all.
* Works with zero props; tune via `color`, `originX`, `variant`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type GlowPopProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The light's color. One hue; a celebration does not need two. */
color?: string;
/** Centre across the box, 0–1. */
originX?: number;
/** Centre down the box, 0–1. */
originY?: number;
/** Fires once the light has gone. */
onComplete?: () => void;
};
type VariantConfig = {
/** Final radius as a fraction of the box's half-diagonal. */
reach: number;
/** Seconds from first light to gone. */
seconds: number;
/** Peak opacity. Deliberately low — this is a glow, not a flash. */
peak: number;
};
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Almost a suggestion. Good behind a checkbox or an inline state.
subtle: { reach: 0.42, seconds: 1.1, peak: 0.26 },
// Visible without asking for anything. All-purpose.
default: { reach: 0.58, seconds: 0.9, peak: 0.38 },
// Travels further and arrives sooner. Still one pulse.
playful: { reach: 0.78, seconds: 0.72, peak: 0.46 },
};
/** `#RRGGBB` plus an alpha, since gradient stops need rgba. */
function withAlpha(hex: string, alpha: number) {
const value = hex.replace("#", "");
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `rgba(${r},${g},${b},${Math.max(0, Math.min(1, alpha)).toFixed(3)})`;
}
export default function GlowPop({
variant = "default",
color = "#6FC7A1",
originX = 0.5,
originY = 0.5,
onComplete,
}: GlowPopProps) {
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 pulse.
const completeRef = useRef(onComplete);
useEffect(() => {
completeRef.current = onComplete;
});
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 render = (progress: number) => {
context.clearRect(0, 0, width, height);
if (progress >= 1) return;
const centreX = width * originX;
const centreY = height * originY;
// Ease out cubic: light arrives quickly and slows. A linear
// expansion reads as a UI ping; deceleration reads as light.
//
// Scale off the half-diagonal, not the shorter side: behind a wide
// short row — a list item, a toast — the shorter side is the row
// height, and a glow sized to that is a smudge under the icon
// instead of light crossing the row.
const extent = Math.hypot(width, height) / 2;
const radius = extent * config.reach * (1 - Math.pow(1 - progress, 3));
if (radius < 0.5) return;
// Fast in, slow out — roughly a fifth of the time to reach full
// strength and the rest of it letting go.
const rise = Math.min(1, progress / 0.18);
const fall = Math.pow(Math.max(0, 1 - (progress - 0.18) / 0.82), 1.6);
const alpha = config.peak * rise * (progress < 0.18 ? 1 : fall);
// The stops, not the size: the core drains as the front travels,
// so the fill becomes a ring on its own.
const core = Math.max(0, 1 - progress * 1.7);
const glow = context.createRadialGradient(centreX, centreY, 0, centreX, centreY, radius);
glow.addColorStop(0, withAlpha(color, alpha * core));
glow.addColorStop(0.62, withAlpha(color, alpha));
glow.addColorStop(0.86, withAlpha(color, alpha * 0.34));
glow.addColorStop(1, withAlpha(color, 0));
context.fillStyle = glow;
context.fillRect(0, 0, width, height);
};
// Reduced motion: the pulse held partway out, where it is widest and
// still clearly lit. A confirmation that stops confirming when the
// setting is on is not a confirmation.
if (reduced) {
render(0.42);
const onResizeStill = () => {
resize();
render(0.42);
};
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;
const progress = elapsed / config.seconds;
render(progress);
if (progress >= 1) {
completeRef.current?.();
return;
}
frame = requestAnimationFrame(tick);
};
frame = requestAnimationFrame(tick);
const onResize = () => {
resize();
render(elapsed / config.seconds);
};
window.addEventListener("resize", onResize);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", onResize);
};
}, [variant, color, originX, originY]);
return (
<canvas
ref={canvasRef}
aria-hidden
style={{ width: "100%", height: "100%", display: "block" }}
/>
);
}About this effect
The most restrained celebration in the library, and deliberately so: a task ticked off or a field accepted does not want confetti. The gradient's stops move rather than the shape scaling — the core drains as the radius grows, so a filled bloom becomes a soft ring and then nothing, which is how light spreads and is not how a sprite scales. One pulse, never two: the variants change how far and how fast it travels and nothing about how many times it happens. Peak opacity stays under half, the radius decelerates, and the count is exactly one, which is why it costs a single gradient fill per frame.