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
Variants
Details
Was Feiern mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Mikroanimationen
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle CelebrateProps aus components/ui/celebrate.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
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
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
'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.