Celebration burst
A brief confetti or sparkle burst that rises from the control that earned it, drawn on a temporary canvas and gone in 1.6 s by default. Pair it with a real text confirmation; it never replaces one.
cmp_celebration_burst_01 Preview
Demonstration — no data is sent
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Use this component with your coding agent
Using the PageSugar MCP server, fetch component cmp_celebration_burst_01 version 1.0.0 with variant "multicolour", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.
Code
Artifact sha256-b929d8ee…b291d6ce
sha256-b929d8eebe5c3abdd40cada129665fa0f8aac6c0eabec74b34c34c5db291d6ce<script module lang="ts">
export type BurstShape = 'confetti' | 'sparkles';
export type BurstScope = 'viewport' | 'container';
/**
* Where the burst starts. `element` is the top of the wrapped element (the control that
* earned it); `center` is the middle of the scope; `{ x, y }` are fractions of the scope's
* width and height, 0 to 1.
*/
export type BurstOrigin = 'element' | 'center' | { x: number; y: number };
</script>
<script lang="ts">
import type { Snippet } from 'svelte';
import { untrack } from 'svelte';
interface Props {
/** Change the value (usually increment it) to fire one burst. The initial value never fires. */
trigger?: number;
shape?: BurstShape;
scope?: BurstScope;
origin?: BurstOrigin;
/** Pieces per burst, capped at 150. Defaults to 64 for confetti and 12 for sparkles. */
particleCount?: number;
/** Milliseconds from the trigger to the canvas being removed. */
duration?: number;
/** CSS colours for the pieces. Defaults to the four palette tokens. */
colors?: string[];
/** The element the burst belongs to. With `scope="container"` it is also the clip. */
children?: Snippet;
}
let {
trigger = 0,
shape = 'confetti',
scope = 'viewport',
origin = 'element',
particleCount,
duration = 1600,
colors,
children
}: Props = $props();
const MAX_PARTICLES = 150;
/** The neutral fallbacks: the burst is monochrome until a palette variant or the consumer colours it. */
const FALLBACK_COLORS = ['#09090b', '#3f3f46', '#71717a', '#a1a1aa'];
/** Physics runs in 60 fps ticks; frames are scaled to that so speed is independent of the refresh rate. */
const TICK_MS = 1000 / 60;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
size: number;
color: string;
piece: 'rect' | 'disc' | 'star';
angle: number;
spin: number;
wobble: number;
wobbleSpeed: number;
decay: number;
/** Sparkles only: the fraction of the burst at which this one appears and disappears. */
start: number;
end: number;
phase: number;
}
interface Burst {
canvas: HTMLCanvasElement;
frame: number;
timer: ReturnType<typeof setTimeout>;
/** Stops watching the motion preference. */
unwatch: () => void;
}
/** The area the canvas covers, in viewport coordinates. */
interface Box {
left: number;
top: number;
width: number;
height: number;
}
let wrapper = $state<HTMLDivElement>();
let burst: Burst | null = null;
// The value at mount is a baseline, not a trigger.
let seen = untrack(() => trigger);
const rand = (min: number, max: number) => min + Math.random() * (max - min);
const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));
function resolvedCount(): number {
const fallback = shape === 'sparkles' ? 12 : 64;
const wanted = Number.isFinite(particleCount) ? Math.round(particleCount as number) : fallback;
return clamp(wanted, 1, MAX_PARTICLES);
}
function resolvedColors(el: HTMLElement): string[] {
const own = (colors ?? []).map((c) => c.trim()).filter(Boolean);
if (own.length) return own;
const style = getComputedStyle(el);
const fromTokens = ['--_color-1', '--_color-2', '--_color-3', '--_color-4']
.map((name) => style.getPropertyValue(name).trim())
.filter(Boolean);
return fromTokens.length ? fromTokens : FALLBACK_COLORS;
}
/** The element the burst belongs to: the first rendered child, never a previous canvas. */
function subject(el: HTMLElement): Element | null {
for (const child of el.children) {
if (child instanceof HTMLCanvasElement) continue;
return child;
}
return null;
}
function resolveOrigin(el: HTMLElement, box: Box): { x: number; y: number } {
if (typeof origin === 'object' && origin !== null) {
const fraction = (value: number) => (Number.isFinite(value) ? clamp(value, 0, 1) : 0.5);
return { x: fraction(origin.x) * box.width, y: fraction(origin.y) * box.height };
}
if (origin === 'element') {
const rect = subject(el)?.getBoundingClientRect();
if (rect && (rect.width > 0 || rect.height > 0)) {
// The burst rises out of the control's top edge rather than through it.
return {
x: rect.left + rect.width / 2 - box.left,
y: rect.top - 2 - box.top
};
}
}
return { x: box.width / 2, y: box.height / 2 };
}
function spawnConfetti(
count: number,
at: { x: number; y: number },
box: Box,
palette: string[]
): Particle[] {
// Reach scales with the space in both axes: a card gets a short hop, a phone a narrow
// fan, a desktop viewport a real arc. Heavy drag makes the rise quick and the fall a flutter.
const speed = clamp(Math.min(box.width * 0.055, box.height * 0.055), 10, 24);
// Most pieces leave along the fan's shoulders, 18–54° off vertical and balanced left and
// right, so they land beside the control rather than back on top of it. One in five goes
// nearer straight up to keep the crown.
return Array.from({ length: count }, (_, i) => {
const off = (Math.random() < 0.2 ? rand(0, 18) : rand(18, 54)) * (Math.PI / 180);
const direction = -Math.PI / 2 + (Math.random() < 0.5 ? off : -off);
const velocity = speed * rand(0.6, 1);
return {
x: at.x,
y: at.y,
vx: Math.cos(direction) * velocity,
vy: Math.sin(direction) * velocity,
size: rand(0.7, 1.3),
color: palette[i % palette.length],
piece: Math.random() < 0.72 ? 'rect' : 'disc',
angle: rand(0, Math.PI * 2),
spin: rand(-0.12, 0.12),
wobble: rand(0, Math.PI * 2),
wobbleSpeed: rand(0.06, 0.12),
decay: rand(0.89, 0.92),
start: 0,
end: 1,
phase: 0
};
});
}
function spawnSparkles(
count: number,
at: { x: number; y: number },
box: Box,
palette: string[]
): Particle[] {
// Glints sit around the control: each appears at its own spot a short way out, turns a
// quarter as it blooms, and drifts only a few pixels. Three size tiers (full spans) give depth.
const reach = clamp(Math.min(box.width, box.height) * 0.09, 24, 56);
const tiers = [7, 11, 17];
return Array.from({ length: count }, (_, i) => {
const direction = rand(0, Math.PI * 2);
const distance = reach * rand(0.35, 1);
const drift = rand(0.15, 0.5);
const start = rand(0, 0.5);
return {
x: at.x + Math.cos(direction) * distance,
y: at.y + Math.sin(direction) * distance * 0.7,
vx: Math.cos(direction) * drift,
vy: Math.sin(direction) * drift,
size: tiers[i % tiers.length] * rand(0.85, 1.15),
color: palette[i % palette.length],
piece: 'star',
angle: rand(-0.4, 0.4),
spin: rand(0.012, 0.024),
wobble: 0,
wobbleSpeed: 0,
decay: 0.97,
start,
end: Math.min(1, start + rand(0.3, 0.5)),
phase: rand(0, Math.PI * 2)
};
});
}
function drawStar(ctx: CanvasRenderingContext2D, r: number) {
// A four-point glint: the curves pull toward the centre, so the points stay sharp.
ctx.beginPath();
ctx.moveTo(0, -r);
ctx.quadraticCurveTo(0, 0, r, 0);
ctx.quadraticCurveTo(0, 0, 0, r);
ctx.quadraticCurveTo(0, 0, -r, 0);
ctx.quadraticCurveTo(0, 0, 0, -r);
ctx.closePath();
ctx.fill();
}
function drawConfetti(
ctx: CanvasRenderingContext2D,
p: Particle,
progress: number,
at: { x: number; y: number }
) {
// Full strength for 70 % of the burst, then a linear fade so nothing snaps away.
let alpha = progress < 0.7 ? 1 : (1 - progress) / 0.3;
// A piece falling back through the control's own column thins out, so the label it
// earned stays readable; the outer pieces keep their weight.
if (p.vy > 0 && p.y > at.y - 40 && Math.abs(p.x - at.x) < 64) alpha *= 0.35;
if (alpha <= 0) return;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = p.color;
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
if (p.piece === 'rect') {
// The tilt is a scale through zero, which is what makes paper look like it tumbles.
ctx.scale(1, Math.cos(p.wobble));
ctx.fillRect(-6.5 * p.size, -2.5 * p.size, 13 * p.size, 5 * p.size);
} else {
ctx.scale(1, 0.75 + 0.25 * Math.cos(p.wobble));
ctx.beginPath();
ctx.arc(0, 0, 3.2 * p.size, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
}
function drawSparkle(
ctx: CanvasRenderingContext2D,
p: Particle,
progress: number,
elapsedMs: number
) {
if (progress < p.start || progress > p.end) return;
const u = (progress - p.start) / (p.end - p.start);
// Scale in fast, hold with a slow shimmer, scale out; alpha follows the same envelope.
const envelope = u < 0.2 ? u / 0.2 : u > 0.65 ? (1 - u) / 0.35 : 1;
// The shimmer is fixed at 1.5 Hz of real time, whatever the duration, and only 15 % deep.
const shimmer = 0.85 + 0.15 * Math.sin((elapsedMs / 1000) * Math.PI * 3 + p.phase);
const scale = envelope * shimmer;
if (scale <= 0) return;
ctx.save();
ctx.globalAlpha = Math.min(1, envelope + 0.1);
ctx.fillStyle = p.color;
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
drawStar(ctx, (p.size / 2) * scale);
ctx.restore();
}
function finish() {
if (!burst) return;
cancelAnimationFrame(burst.frame);
clearTimeout(burst.timer);
burst.unwatch();
burst.canvas.remove();
burst = null;
}
function fire() {
const el = wrapper;
if (!el || typeof window === 'undefined') return;
// One celebration at a time: triggers that land during a burst join it.
if (burst) return;
const motion = window.matchMedia?.('(prefers-reduced-motion: reduce)');
if (motion?.matches) return;
const contained = scope === 'container';
const box: Box = contained
? el.getBoundingClientRect()
: { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight };
if (box.width < 1 || box.height < 1) return;
const canvas = document.createElement('canvas');
// Two complete class lists, so the page stays clickable and the canvas sits above everything.
canvas.className = contained
? 'celebration-burst__canvas pointer-events-none absolute inset-0 z-[9999] block h-full w-full'
: 'celebration-burst__canvas pointer-events-none fixed inset-0 z-[9999] block h-full w-full';
canvas.setAttribute('aria-hidden', 'true');
if (contained) {
// Follow the wrapped surface's corners so nothing lands outside a rounded card.
const radius = subject(el) ? getComputedStyle(subject(el) as Element).borderRadius : '';
if (radius) canvas.style.borderRadius = radius;
}
const dpr = clamp(window.devicePixelRatio || 1, 1, 3);
canvas.width = Math.round(box.width * dpr);
canvas.height = Math.round(box.height * dpr);
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.scale(dpr, dpr);
const palette = resolvedColors(el);
const at = resolveOrigin(el, box);
const count = resolvedCount();
// Everything about this burst is settled now; a prop that changes mid-flight waits for the next.
const sparkles = shape === 'sparkles';
const particles = sparkles
? spawnSparkles(count, at, box, palette)
: spawnConfetti(count, at, box, palette);
const total = Number.isFinite(duration) ? Math.max(300, duration) : 1600;
// Light gravity against heavy drag: pieces flutter down at a paper's pace, slower in a card.
const gravity = sparkles ? 0 : 0.34 * clamp(box.height / 900, 0.55, 1);
const started = performance.now();
let previous = started;
(contained ? el : document.body).appendChild(canvas);
const step = (now: number) => {
const elapsed = now - started;
const progress = elapsed / total;
if (progress >= 1) {
finish();
return;
}
// Cap the step so a stalled tab does not teleport every piece off screen.
const ticks = Math.min((now - previous) / TICK_MS, 3);
previous = now;
ctx.clearRect(0, 0, box.width, box.height);
for (const p of particles) {
const drag = Math.pow(p.decay, ticks);
p.vx *= drag;
p.vy = p.vy * drag + gravity * ticks;
p.x += p.vx * ticks + (p.piece === 'rect' ? Math.sin(p.wobble) * 0.35 * ticks : 0);
p.y += p.vy * ticks;
p.angle += p.spin * ticks;
p.wobble += p.wobbleSpeed * ticks;
if (p.y > box.height + 24 || p.x < -24 || p.x > box.width + 24) continue;
if (p.piece === 'star') drawSparkle(ctx, p, progress, elapsed);
else drawConfetti(ctx, p, progress, at);
}
if (burst) burst.frame = requestAnimationFrame(step);
};
// A visitor who turns reduced motion on mid-burst gets the quiet they asked for.
const onMotionChange = () => {
if (motion?.matches) finish();
};
motion?.addEventListener?.('change', onMotionChange);
burst = {
canvas,
frame: requestAnimationFrame(step),
// requestAnimationFrame pauses in a hidden tab; the canvas still goes at the duration.
timer: setTimeout(finish, total),
unwatch: () => motion?.removeEventListener?.('change', onMotionChange)
};
}
$effect(() => {
// Object.is, so a NaN baseline never reads as a change.
if (Object.is(trigger, seen)) return;
seen = trigger;
untrack(fire);
});
$effect(() => {
// A burst belongs to the scope it started in; changing scope ends it rather than orphaning it.
void scope;
untrack(finish);
});
$effect(() => finish);
</script>
<div
bind:this={wrapper}
class={['celebration-burst', scope === 'container' ? 'relative' : 'contents']}
>
{#if children}
{@render children()}
{/if}
</div>
<style>
.celebration-burst {
--_color-1: var(--celebration-burst-1, #f2b23d);
--_color-2: var(--celebration-burst-2, #e4604e);
--_color-3: var(--celebration-burst-3, #2f9e8f);
--_color-4: var(--celebration-burst-4, #4b5fe0);
}
</style>
Usage #
On this pageWrap the control that completes the action and change trigger after your backend confirms success. The component draws one burst on a temporary canvas and removes it after duration. It does not confirm anything: keep your own success message, because the canvas is hidden from assistive technology and the burst is skipped entirely when the visitor prefers reduced motion. It plays no sound and makes no requests.
- Suggested location
src/lib/components/celebration-burst-01
Limitations
- Nothing is announced: the canvas is
aria-hidden, so the text confirmation is yours to provide. - Under prefers-reduced-motion the trigger does nothing; there is no static alternative.
- The canvas is sized when the burst starts. Resizing the window during a burst is not tracked.
- With scope="viewport" the wrapper is a div with display: contents and the canvas is appended to
document.body; the wrapper is still a div in the HTML, so place the component in flow content, not inside a paragraph. - With scope="container" the canvas covers the wrapper's box and copies the first child's border-radius, so the child should fill the wrapper. A wrapper with no size draws nothing.
- duration has a 300 ms floor and no ceiling; non-finite values fall back to 1600. Removal is scheduled by a browser timer, so a suspended tab can be a little late.
Example
<!-- Fire only after your own request succeeds, and keep a visible confirmation. -->
<script lang="ts">
import CelebrationBurst from '$lib/components/celebration-burst-01/CelebrationBurst.svelte';
let celebrate = $state(0);
let status = $state('');
async function placeOrder() {
const response = await fetch('/api/orders', { method: 'POST' });
if (!response.ok) {
status = 'We could not place the order. Try again in a moment.';
return;
}
status = 'Order placed. The receipt is on its way to your inbox.';
celebrate += 1;
}
</script>
<CelebrationBurst trigger={celebrate}>
<button type="button" onclick={placeOrder}>Place order</button>
</CelebrationBurst>
<p role="status" aria-live="polite">{status}</p>Using the burst #
Copy CelebrationBurst.svelte into src/lib/components/celebration-burst-01/. Wrap the control that completes the action, keep a counter in state, and add one to it after your request succeeds. Each change to trigger plays one burst; the value the component mounts with never does.
Pair it with a confirmation #
The burst is decoration for a success that is already on screen. The canvas is aria-hidden, nothing is announced, and when the visitor has asked for reduced motion the trigger is ignored outright. So the sequence is always: the server confirms, your success state or status message appears (a polite live region, or a component covering the success-state pattern), and then trigger changes. Never fire it optimistically, and never let it be the only sign that something worked.
Origin and scope #
origin="element" (the default) starts the burst at the top of the first element inside the wrapper, so confetti rises out of the button rather than through it. origin="center" starts from the middle of the scope. { x, y } are fractions of the scope's width and height, which is the way to aim at a number or a headline.
scope="viewport" appends a fixed canvas to document.body, which keeps it clear of any ancestor with a transform. scope="container" makes the wrapper a positioned block and draws inside it, following the first child's border-radius so pieces stay inside a rounded card; wrap the card itself, not its contents.
Rapid triggers #
Triggers that land while a burst is playing join it: one canvas, one burst, removed at the end of the first one's duration. A double-submitted form or an effect that runs twice therefore produces one celebration, not two.
Colours #
The pieces cycle through --celebration-burst-1 to --celebration-burst-4, read from the wrapper at the moment the burst starts, so set them on any ancestor. The colors prop replaces the tokens for one instance. Four steps of one hue reads as a brand; four unrelated hues reads as a party. Pick deliberately.
Cleanup #
The canvas is removed when the burst ends, when a hidden tab's timer fires, or when the component is destroyed, and the animation frame and timer go with it.
Props and content inputs #
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
trigger | number | No | 0 | Change the value (usually increment it) to fire one burst. The value at mount never fires, and changes during a burst join the running one. |
shape | 'confetti' | 'sparkles' | No | 'confetti' | Tumbling paper in an upward cone, or four-point glints that drift outward and shimmer. Read when a burst starts; a change mid-burst applies to the next one. |
scope | 'viewport' | 'container' | No | 'viewport' | A fixed canvas over the whole viewport, or one clipped to the wrapper's box and its first child's corners. Changing scope ends a running burst. |
origin | 'element' | 'center' | { x: number; y: number } | No | 'element' | Where the burst starts: the top edge of the wrapped element, the centre of the scope, or fractions of the scope's width and height. Without children, 'element' falls back to the centre. |
particleCount | number | No | 64 for confetti, 12 for sparkles | Pieces per burst, capped at 150. |
duration | number | No | 1600 | Milliseconds from the trigger until the canvas is removed, 300 at least. Confetti fades over the last 30 %. |
colors | string[] | No | the four palette tokens | CSS colours assigned to pieces in turn. A list with at least one non-blank entry replaces the palette tokens; an empty list falls back to them. Without tokens or colours the burst is zinc: neutral by default, like every component here. |
children | Snippet | No | None | The control or surface the burst belongs to. It is rendered unchanged and used as the origin. |
Customization #
On this pageSet the four palette variables on any ancestor, or pass colors for a one-off list. Shape, origin, scope, count and duration are props; the physics constants sit at the top of fire() in the source.
- Colours: set
--celebration-burst-1to--celebration-burst-4on an ancestor, or pass colors={[...]} to bypass the tokens for one instance. The source's own fallbacks are four zinc steps, so an unstyled burst is monochrome. - Brand palette: give the four tokens four steps of one hue so the burst reads as yours rather than as a party.
- Origin: leave 'element' so the burst rises from the control; use 'center' for a page-level moment, or
{ x, y }fractions to aim at a number or a headline. - Scope: 'container' keeps the burst inside a card. Wrap the card itself, not its contents, so the clip matches its corners.
- Weight:
particleCountand duration tune the size of the moment. 150 pieces is the hard cap; 2.6 s reads as a ceremony and 0.9 s as a nod. - Physics: gravity, spread, speed and decay are named constants inside
spawnConfettiandspawnSparklesin the source. - Layering: the canvas uses z-[9999]; lower it in the class list if your own overlays sit above it.
Public CSS variables
| Variable | Token |
|---|---|
--celebration-burst-1 | color1 |
--celebration-burst-2 | color2 |
--celebration-burst-3 | color3 |
--celebration-burst-4 | color4 |
Accessibility #
On this page- The canvas is
aria-hiddenand pointer-events: none, so assistive technology never sees it and the page stays clickable while it plays. - You provide the confirmation: a visible message in a polite live region, or a success state, alongside the trigger. The burst carries no meaning on its own.
- When prefers-reduced-motion is set the trigger is ignored and no canvas is created; if it is turned on during a burst, the burst ends. Your confirmation must stand alone.
- Pieces are small and each crosses the screen once. Confetti tumbles at no more than about 2.3 turns a second and sparkles shimmer at 1.5 Hz with a 15 % depth, so no region flashes in the WCAG 2.3.1 sense; check your own colours against the general flash threshold if you go far outside the defaults.
- Focus is never moved, and the wrapped control keeps its own semantics; in viewport scope the wrapper is a plain div with display: contents, in container scope a positioned block.
Known limitations
- No static alternative is drawn under reduced motion; add your own if the moment needs a visual mark for every visitor.
Release details #
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 Published Current release · 22 September 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.