Zum Inhalt springen
UI Kit
Accent

Feiern

Momente der Freude für deine Seite, von Konfetti und Regen über einer Karte bis zu Feuerwerk, Kanonen von der Seite und funkelnden Sternen. Eine feste Ebene, die die Seite nie verbreitert, Farben aus dem Theme, ruhig bei reduzierter Bewegung. Aus dem Code, per Klick oder beim Hereinscrollen.

Eine von 4 Components in Mikroanimationen

5 Variants

Variants

Veröffentlichen-Knopf

Der Knopf arbeitet, wird zum Haken, und Konfetti springt aus ihm heraus. So endet eine lange Aufgabe.

Im Studio öffnen

Erfolgskarte

Eine Bestellbestätigung, über deren Karte beim Hereinscrollen ein sanfter Konfettiregen fällt.

Im Studio öffnen

Meilenstein

Eine Zahl zählt bis zum Meilenstein, dann öffnen sich darüber drei Feuerwerksbögen.

Im Studio öffnen

Start-Kanonen

Konfetti schießt aus beiden unteren Ecken des Fensters, sobald das Start-Banner im Bild ist, der Knopf feuert erneut.

Im Studio öffnen

Funkeln

Kleine Sterne funkeln um ein Wort und ein Badge, beim Hover schneller. Der Knopf lässt ein kleines eigenes Konfetti springen.

Im Studio öffnen

Details

Was Feiern mitbringt, aus Registry und Quellcode gelesen.

Bereich
Visuals
Variants
5
Version
1.0.0 · seit 1.1.0
Datei
components/ui/celebrate.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Texte kommen über Props

Props

Die Schnittstelle CelebrateProps aus components/ui/celebrate.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • 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

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 256 Zeilen
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>
  );
}

Häufige Fragen

Wie installiere ich Feiern?

Mit aiw add @ai-website/celebrate. Der Befehl legt die Datei components/ui/celebrate.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Feiern?

5 Varianten, Veröffentlichen-Knopf, Erfolgskarte, Meilenstein, Start-Kanonen und Funkeln.

Kann ein KI-Assistent Feiern einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Feiern mit aiw_components und installieren sie mit aiw_add @ai-website/celebrate.

Kann ich Feiern ohne Code anpassen?

Ja, im Studio. Alle 5 Varianten lassen sich dort bearbeiten, Texte, Listen und Schalter. Das Studio schreibt den passenden Celebrate-Code zum Kopieren.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert