Celebrate
Moments of joy for your page, from confetti bursts and a rain over a card to fireworks, side cannons and twinkling sparkles. One fixed layer that never widens the page, theme colors, calm with reduced motion. Fire it from code, on click or when it scrolls into view.
One of 4 components in Micro animations
Variants
Details
What Celebrate brings along, read from the registry and the source.
- Category
- Micro animations
- Area
- Visuals
- Variants
- 5
- Version
- 1.0.0 · since 1.1.0
- File
- components/ui/celebrate.tsx
- Brings along
- utilskit
- npm packages
- None
- Languages
- Texts come through props
- Studio
- All 5 variants editable
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The CelebrateProps interface from components/ui/celebrate.tsx, with the comments from the code.
kindoptionalCelebrateKindburstrainfireworkscannonspop
triggeroptional'click' | 'view' | 'manual'click: on every click inside. view: once when it scrolls into view. manual: only when `fire` changes.
fireoptionalnumberWith trigger manual: change this number to fire again
countoptionalnumberpoweroptionalnumbercolorsoptionalstring[]delayoptionalnumberMilliseconds after the trigger
childrenoptionalReact.ReactNode
Accessibility
Read from the source of the component.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 256 lines
'use client';
import { useCallback, useEffect, useRef } from 'react';
import { cn, prefersReducedMotion, useInView } from '@/lib/kit';
/*
* Moments of joy: confetti, fireworks, side cannons, a gentle rain and
* sparkles. Drawn as small DOM pieces in one fixed layer over the page that
* moves only transform and opacity and cleans itself up, so nothing ever
* widens the page or blocks a click.
*
* useCelebrate() fire(kind, origin) from your own code, e.g. after a payment
* <Celebrate> fires on click, when it scrolls into view, or when you say
* <Sparkles> small stars that twinkle around its children on hover and in view
*
* Colors come from the theme: the accent, its neighbour, success, warning and
* the text color. With reduced motion nothing flies, the moment stays calm.
*/
export type CelebrateKind = 'burst' | 'rain' | 'fireworks' | 'cannons' | 'pop';
export interface FireOptions {
/** Where it starts: an element, a point in window pixels, or the center of the window */
origin?: Element | { x: number; y: number } | null;
/** Number of pieces, capped at 160 */
count?: number;
/** Colors as CSS values, default the theme colors */
colors?: string[];
/** 1 is the designed force */
power?: number;
}
const THEME_COLORS = ['var(--primary)', 'var(--primary-2)', 'var(--success)', 'var(--warning)', 'color-mix(in oklab, var(--foreground) 80%, transparent)', 'color-mix(in oklab, var(--primary) 45%, var(--background))'];
let layer: HTMLDivElement | null = null;
/** One fixed, clipped layer for every piece on the page */
function getLayer() {
if (layer && document.body.contains(layer)) return layer;
layer = document.createElement('div');
layer.setAttribute('aria-hidden', 'true');
layer.setAttribute('data-clip-ok', '');
Object.assign(layer.style, { position: 'fixed', inset: '0', overflow: 'hidden', pointerEvents: 'none', zIndex: '60', contain: 'strict' });
document.body.appendChild(layer);
return layer;
}
function originPoint(origin: FireOptions['origin']) {
if (!origin) return { x: window.innerWidth / 2, y: window.innerHeight / 2, w: 0, h: 0 };
if ('getBoundingClientRect' in origin) {
const r = origin.getBoundingClientRect();
return { x: r.left + r.width / 2, y: r.top + r.height / 2, w: r.width, h: r.height };
}
return { ...origin, w: 0, h: 0 };
}
interface Piece {
x: number;
y: number;
vx: number;
vy: number;
spin: number;
size: number;
shape: 'rect' | 'strip' | 'dot' | 'spark';
color: string;
life: number;
delay: number;
gravity: number;
drag: number;
}
/** Throws pieces with a simple ballistic path, sampled into keyframes */
function launch(pieces: Piece[]) {
const root = getLayer();
for (const p of pieces) {
const el = document.createElement('span');
const w = p.shape === 'strip' ? p.size * 0.45 : p.shape === 'spark' ? 2.5 : p.size;
const h = p.shape === 'strip' ? p.size * 1.6 : p.shape === 'spark' ? p.size * 2.2 : p.shape === 'dot' ? p.size : p.size * 0.6;
Object.assign(el.style, {
position: 'absolute',
left: `${p.x}px`,
top: `${p.y}px`,
width: `${w}px`,
height: `${h}px`,
marginLeft: `${-w / 2}px`,
marginTop: `${-h / 2}px`,
background: p.color,
borderRadius: p.shape === 'dot' || p.shape === 'spark' ? '999px' : '1.5px',
willChange: 'transform, opacity',
});
root.appendChild(el);
const frames: Keyframe[] = [];
const steps = 8;
for (let i = 0; i <= steps; i++) {
const t = i / steps;
// velocity fades with drag, gravity pulls down
const k = (1 - Math.exp(-p.drag * t)) / p.drag;
const dx = p.vx * k;
const dy = p.vy * k + 0.5 * p.gravity * t * t;
const flutter = p.shape === 'spark' ? 0 : Math.sin(t * 9 + p.spin) * 6 * t;
const rot = p.shape === 'spark' ? Math.atan2(p.vy + p.gravity * t, p.vx) * (180 / Math.PI) + 90 : p.spin * 360 * t;
frames.push({
transform: `translate(${(dx + flutter).toFixed(1)}px, ${dy.toFixed(1)}px) rotate(${rot.toFixed(1)}deg) rotateX(${(t * 720 * (p.spin > 0 ? 1 : -1)).toFixed(0)}deg)`,
opacity: t < 0.75 ? 1 : 1 - (t - 0.75) / 0.25,
});
}
const anim = el.animate(frames, { duration: p.life, delay: p.delay, easing: 'linear', fill: 'both' });
anim.onfinish = () => el.remove();
anim.oncancel = () => el.remove();
}
}
/**
* Fire confetti from code.
* const fire = useCelebrate();
* fire('burst', { origin: buttonRef.current })
*/
export function useCelebrate() {
return useCallback((kind: CelebrateKind = 'burst', opts: FireOptions = {}) => {
if (typeof window === 'undefined' || prefersReducedMotion()) return;
const colors = opts.colors?.length ? opts.colors : THEME_COLORS;
const power = opts.power ?? 1;
const o = originPoint(opts.origin);
const r = Math.random;
const pick = () => colors[Math.floor(r() * colors.length)];
const shapes: Piece['shape'][] = ['rect', 'rect', 'strip', 'dot'];
const shape = () => shapes[Math.floor(r() * shapes.length)];
const pieces: Piece[] = [];
const cap = (n: number) => Math.min(160, Math.max(1, Math.round(opts.count ?? n)));
const H = window.innerHeight;
if (kind === 'burst' || kind === 'pop') {
const n = cap(kind === 'pop' ? 28 : 70);
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + (r() - 0.5) * (kind === 'pop' ? Math.PI * 2 : Math.PI * 1.1);
const v = (kind === 'pop' ? 160 : 520) * power * (0.45 + r() * 0.75);
pieces.push({ x: o.x + (r() - 0.5) * o.w * 0.6, y: o.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v, spin: (r() - 0.5) * 4, size: 7 + r() * 6, shape: shape(), color: pick(), life: (kind === 'pop' ? 900 : 1900) + r() * 700, delay: r() * 60, gravity: kind === 'pop' ? 180 : 900, drag: 2.2 });
}
} else if (kind === 'rain') {
const n = cap(90);
const left = opts.origin ? o.x - o.w / 2 : 0;
const width = opts.origin ? o.w : window.innerWidth;
const top = opts.origin ? o.y - o.h / 2 : 0;
for (let i = 0; i < n; i++) {
pieces.push({ x: left + r() * width, y: top - 20 - r() * 40, vx: (r() - 0.5) * 60, vy: 40 + r() * 60, spin: (r() - 0.5) * 3, size: 7 + r() * 5, shape: shape(), color: pick(), life: 2600 + r() * 1600, delay: r() * 1400, gravity: (opts.origin ? o.h : H) * 0.9 * power, drag: 1 });
}
} else if (kind === 'cannons') {
const n = cap(60);
for (const side of [-1, 1]) {
const x = side < 0 ? 0 : window.innerWidth;
for (let i = 0; i < n; i++) {
const a = -Math.PI / 2 + side * -(0.35 + r() * 0.5);
const v = 900 * power * (0.55 + r() * 0.6);
pieces.push({ x, y: H, vx: Math.cos(a) * v, vy: Math.sin(a) * v, spin: (r() - 0.5) * 4, size: 7 + r() * 6, shape: shape(), color: pick(), life: 2400 + r() * 900, delay: r() * 120, gravity: 1300, drag: 1.8 });
}
}
} else if (kind === 'fireworks') {
const shells = 3;
for (let s = 0; s < shells; s++) {
const cx = o.x + (s - 1) * Math.min(220, window.innerWidth * 0.22) + (r() - 0.5) * 40;
const cy = o.y - 40 - r() * 60;
const color = colors[s % colors.length];
const n = cap(36);
for (let i = 0; i < n; i++) {
const a = (i / n) * Math.PI * 2 + r() * 0.2;
const v = 230 * power * (0.8 + r() * 0.3);
pieces.push({ x: cx, y: cy, vx: Math.cos(a) * v, vy: Math.sin(a) * v, spin: 0, size: 5 + r() * 3, shape: 'spark', color, life: 1300 + r() * 300, delay: s * 380, gravity: 160, drag: 2.6 });
}
}
}
launch(pieces);
}, []);
}
/* ------------------------------------------------------------------ */
export interface CelebrateProps {
kind?: CelebrateKind;
/** click: on every click inside. view: once when it scrolls into view. manual: only when `fire` changes. */
trigger?: 'click' | 'view' | 'manual';
/** With trigger manual: change this number to fire again */
fire?: number;
count?: number;
power?: number;
colors?: string[];
/** Milliseconds after the trigger */
delay?: number;
className?: string;
children?: React.ReactNode;
}
/**
* Fires a celebration from its own box.
* <Celebrate kind="burst" trigger="click"><Button>Publish</Button></Celebrate>
*/
export function Celebrate({ kind = 'burst', trigger = 'click', fire: fireKey, count, power, colors, delay = 0, className, children }: CelebrateProps) {
const fire = useCelebrate();
const [ref, inView] = useInView<HTMLDivElement>('0px 0px -20% 0px');
const done = useRef(false);
const go = useCallback(() => {
const id = window.setTimeout(() => fire(kind, { origin: ref.current, count, power, colors }), delay);
return () => window.clearTimeout(id);
}, [fire, kind, count, power, colors, delay, ref]);
useEffect(() => {
if (trigger !== 'view' || !inView || done.current) return;
done.current = true;
return go();
}, [trigger, inView, go]);
const first = useRef(true);
useEffect(() => {
if (trigger !== 'manual') return;
if (first.current) {
first.current = false;
return;
}
return go();
}, [fireKey, trigger, go]);
return (
<div ref={ref} className={cn('relative', className)} onClickCapture={trigger === 'click' ? () => go() : undefined}>
{children}
</div>
);
}
/* ------------------------------------------------------------------ */
/* Sparkles */
/* ------------------------------------------------------------------ */
const SPARKLE_SPOTS: [number, number, number, number][] = [
// x %, y %, size px, delay ms
[-6, 12, 14, 0],
[104, 4, 11, 260],
[96, 92, 16, 520],
[10, 100, 10, 780],
[52, -14, 12, 1040],
[-10, 70, 9, 1300],
];
/**
* Small four-pointed stars that twinkle around its children, in view and
* brighter on hover. Placed on fixed spots, so server and browser agree.
* <Sparkles><span>new</span></Sparkles>
*/
export function Sparkles({ children, tone = 'accent', className }: { children: React.ReactNode; tone?: 'accent' | 'warning' | 'foreground'; className?: string }) {
const color = tone === 'accent' ? 'var(--primary)' : tone === 'warning' ? 'var(--warning)' : 'var(--foreground)';
return (
<span className={cn('group/sparkle relative inline-block', className)}>
<style href="kit-sparkles" precedence="default">{`@keyframes ksk-twinkle{0%,100%{transform:scale(0) rotate(0deg);opacity:0}40%{transform:scale(1) rotate(45deg);opacity:1}70%{transform:scale(.6) rotate(90deg);opacity:.6}}.ksk-star{animation:ksk-twinkle 2.6s var(--ease-kit) infinite both}.group\\/sparkle:hover .ksk-star{animation-duration:1.3s}@media (prefers-reduced-motion:reduce){.ksk-star{animation:none;transform:scale(.8);opacity:.8}}`}</style>
{SPARKLE_SPOTS.map(([x, y, s, d], i) => (
<svg key={i} aria-hidden viewBox="0 0 24 24" className="ksk-star pointer-events-none absolute" style={{ left: `${x}%`, top: `${y}%`, width: s, height: s, marginLeft: -s / 2, marginTop: -s / 2, animationDelay: `${d}ms`, color }}>
<path d="M12 0c.8 6.4 5.6 11.2 12 12-6.4.8-11.2 5.6-12 12-.8-6.4-5.6-11.2-12-12C6.4 11.2 11.2 6.4 12 0Z" fill="currentColor" />
</svg>
))}
<span className="relative">{children}</span>
</span>
);
}Frequent questions
How do I install Celebrate?
With aiw add @ai-website/celebrate. The command puts components/ui/celebrate.tsx into your project, the code is yours from then on. It needs no further npm packages.
Which variants does Celebrate have?
5 variants, Publish button, Success card, Milestone, Launch cannons and Sparkles.
Can an AI assistant build with Celebrate?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Celebrate with aiw_components and install it with aiw_add @ai-website/celebrate.
Can I adjust Celebrate without code?
Yes, in the Studio. All 5 variants are editable there, texts, lists and switches. The Studio writes the matching Celebrate code to copy.