Skip to content
UI Kit
Accent color

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

5 variants

Variants

Publish button

The button works, turns into a check and confetti bursts from it. The way to end a long task.

Open in Studio

Success card

An order confirmation that gets a gentle rain of confetti over its own box as it scrolls into view.

Open in Studio

Milestone

A number counts up to the milestone, then three shells of fireworks open above it.

Open in Studio

Launch cannons

Confetti shoots in from both lower corners of the window when the launch banner comes into view, the button fires again.

Open in Studio

Sparkles

Small stars twinkle around a word and a badge, faster on hover. The button pops a small burst of its own.

Open in Studio

Details

What Celebrate brings along, read from the registry and the source.

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

Props

The CelebrateProps interface from components/ui/celebrate.tsx, with the comments from the code.

  • kindoptional
    CelebrateKind

    burstrainfireworkscannonspop

  • triggeroptional
    'click' | 'view' | 'manual'

    click: on every click inside. view: once when it scrolls into view. manual: only when `fire` changes.

  • fireoptional
    number

    With trigger manual: change this number to fire again

  • countoptional
    number
  • poweroptional
    number
  • colorsoptional
    string[]
  • delayoptional
    number

    Milliseconds after the trigger

  • childrenoptional
    React.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
components/ui/celebrate.tsx
'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.

Often searched

Jump to

Copied to the clipboard