Status-Animationen
Haken, die sich zeichnen, ein Fehler, der einmal wackelt, drei Spinner, ein Ring, der sich füllt, und ein Live-Puls. Nur SVG und CSS.
Eine von 4 Components in Mikroanimationen
Variants
Details
Was Status-Animationen mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Mikroanimationen
- Bereich
- Visuals
- Variants
- 6
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/status-animation.tsx
- Bringt mit
- utilskitmotion-scene
- npm-Pakete
- Keine
- Sprachen
- Texte kommen über Props
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle StatusAnimationProps aus components/ui/status-animation.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
kindPflichtStatusKindsuccesserrorwarningloadingprogresspulse
sizeoptionalnumberSize in px. Default 64.
spinneroptional'ring' | 'dots' | 'bars'loading only: ring (default), dots or bars
valueoptionalnumberprogress only: 0 to 1
loopoptionalbooleanReplay the symbol every few seconds instead of once. Default false (loading and pulse always run).
autoPlayoptionalAutoPlayWhen it plays. Default in-view.
speedoptionalnumber1 is the designed tempo
labeloptionalstringRead to screen readers, e.g. "Payment received". Without it the symbol is decorative.
childrenoptionalReact.ReactNodeprogress only: the text in the middle, e.g. a percentage
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-label, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle label.
- Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.
Bewegung
- Ohne Animationen, sie steht sofort ruhig da.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 214 Zeilen
'use client';
import { cn, round } from '@/lib/kit';
import { SCENE_CSS, keyframes, useSceneId, useScenePlay, type AutoPlay } from '@/components/ui/motion-scene';
/*
* Status in motion: a check that draws itself, an error that shakes once,
* spinners, a filling ring and a live pulse. SVG only, strokes draw with
* stroke-dashoffset, everything else is transform and opacity. The server
* and reduced motion show the finished symbol (or a still spinner).
*/
export type StatusKind = 'success' | 'error' | 'warning' | 'loading' | 'progress' | 'pulse';
export interface StatusAnimationProps {
kind: StatusKind;
/** Size in px. Default 64. */
size?: number;
/** loading only: ring (default), dots or bars */
spinner?: 'ring' | 'dots' | 'bars';
/** progress only: 0 to 1 */
value?: number;
/** Replay the symbol every few seconds instead of once. Default false (loading and pulse always run). */
loop?: boolean;
/** When it plays. Default in-view. */
autoPlay?: AutoPlay;
/** 1 is the designed tempo */
speed?: number;
/** Read to screen readers, e.g. "Payment received". Without it the symbol is decorative. */
label?: string;
className?: string;
/** progress only: the text in the middle, e.g. a percentage */
children?: React.ReactNode;
}
const TONE: Record<StatusKind, string> = {
success: 'var(--success)',
error: 'var(--destructive)',
warning: 'var(--warning)',
loading: 'var(--primary)',
progress: 'var(--primary)',
pulse: 'var(--success)',
};
/**
* <StatusAnimation kind="success" label="Payment received" />
* <StatusAnimation kind="loading" spinner="dots" />
* <StatusAnimation kind="progress" value={0.72}>72%</StatusAnimation>
*/
export function StatusAnimation({ kind, size = 64, spinner = 'ring', value = 0.66, loop = false, autoPlay = 'in-view', speed = 1, label, className, children }: StatusAnimationProps) {
const sid = useSceneId('kvst');
const play = useScenePlay<HTMLSpanElement>(autoPlay);
const sp = Math.max(0.1, speed);
const tone = TONE[kind];
const once = !loop;
// A one-shot symbol lasts 1.4 s; looped it rests and replays every 3.2 s
const T = round((once ? 1.4 : 3.2) / sp, 2);
const it = once ? '1' : 'infinite';
const p = (s: number) => (s / (once ? 1.4 : 3.2)) * 100;
const draw = (name: string, from: number, to: number): string =>
keyframes(name, [
[0, 'stroke-dashoffset:1'],
[p(from), 'stroke-dashoffset:1;animation-timing-function:cubic-bezier(.65,0,.35,1)'],
[p(to), 'stroke-dashoffset:0'],
...(once ? [] : ([[p(2.7), 'stroke-dashoffset:0;opacity:1'], [p(3.0), 'stroke-dashoffset:0;opacity:0'], [100, 'stroke-dashoffset:1;opacity:0']] as [number, string][])),
]);
const pop = keyframes(`${sid}-pop`, [
[0, 'transform:scale(.6);opacity:0'],
[p(0.05), 'transform:scale(.6);opacity:0;animation-timing-function:cubic-bezier(.3,1.5,.5,1)'],
[p(0.45), 'transform:scale(1);opacity:1'],
...(once ? [] : ([[p(2.7), 'transform:scale(1);opacity:1'], [p(3.0), 'transform:scale(.9);opacity:0'], [100, 'transform:scale(.6);opacity:0']] as [number, string][])),
]);
const burst = keyframes(`${sid}-burst`, [
[0, 'transform:scale(.5);opacity:0'],
[p(0.75), 'transform:scale(.55);opacity:0'],
[p(0.82), 'transform:scale(.75);opacity:1'],
[p(1.3), 'transform:scale(1.15);opacity:0'],
[100, 'transform:scale(1.15);opacity:0'],
]);
const shake = keyframes(`${sid}-shake`, [
[0, 'transform:translateX(0)'],
[p(0.8), 'transform:translateX(0)'],
[p(0.88), 'transform:translateX(-6%)'],
[p(0.96), 'transform:translateX(5%)'],
[p(1.04), 'transform:translateX(-3%)'],
[p(1.12), 'transform:translateX(2%)'],
[p(1.2), 'transform:translateX(0)'],
[100, 'transform:translateX(0)'],
]);
const drop = keyframes(`${sid}-drop`, [
[0, 'transform:translateY(-30%);opacity:0'],
[p(0.7), 'transform:translateY(-30%);opacity:0;animation-timing-function:cubic-bezier(.3,1.4,.5,1)'],
[p(1.05), 'transform:translateY(0);opacity:1'],
...(once ? [] : ([[p(2.7), 'transform:translateY(0);opacity:1'], [p(3.0), 'opacity:0'], [100, 'opacity:0']] as [number, string][])),
]);
const css = `${SCENE_CSS}
${draw(`${sid}-ring`, 0, 0.55)}${draw(`${sid}-mark`, 0.45, 0.85)}${draw(`${sid}-mark2`, 0.6, 0.95)}${pop}${burst}${shake}${drop}
${keyframes(`${sid}-spin`, [[0, 'transform:rotate(0deg)'], [100, 'transform:rotate(360deg)']])}
${keyframes(`${sid}-dot`, [[0, 'transform:scale(.55);opacity:.4'], [35, 'transform:scale(1);opacity:1'], [70, 'transform:scale(.55);opacity:.4'], [100, 'transform:scale(.55);opacity:.4']])}
${keyframes(`${sid}-bar`, [[0, 'transform:scaleY(.35)'], [35, 'transform:scaleY(1)'], [70, 'transform:scaleY(.35)'], [100, 'transform:scaleY(.35)']])}
${keyframes(`${sid}-fill`, [[0, `stroke-dashoffset:1`], [100, `stroke-dashoffset:${round(1 - Math.min(1, Math.max(0, value)), 4)}`]])}
${keyframes(`${sid}-ripple`, [[0, 'transform:scale(1);opacity:.55'], [100, 'transform:scale(2.6);opacity:0']])}
.${sid}:not([data-kv="off"]) .${sid}-ring { animation: ${sid}-ring ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-mark { animation: ${sid}-mark ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-mark2 { animation: ${sid}-mark2 ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-pop { animation: ${sid}-pop ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-burst { animation: ${sid}-burst ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-shake { animation: ${sid}-shake ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-drop { animation: ${sid}-drop ${T}s linear ${it} both; }
.${sid}:not([data-kv="off"]) .${sid}-spin { animation: ${sid}-spin ${round(1.1 / sp, 2)}s cubic-bezier(.5,.15,.5,.85) infinite; }
.${sid}:not([data-kv="off"]) .${sid}-dot { animation: ${sid}-dot ${round(1.1 / sp, 2)}s ease-in-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-bar { animation: ${sid}-bar ${round(1 / sp, 2)}s ease-in-out var(--d) infinite both; }
.${sid}:not([data-kv="off"]) .${sid}-fill { animation: ${sid}-fill ${round(1.6 / sp, 2)}s cubic-bezier(.3,.7,.2,1) 1 both; }
.${sid}:not([data-kv="off"]) .${sid}-ripple { animation: ${sid}-ripple ${round(2.4 / sp, 2)}s cubic-bezier(.2,.6,.3,1) var(--d) infinite both; }`;
const stroke = { fill: 'none', stroke: tone, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const };
let body: React.ReactNode;
if (kind === 'success' || kind === 'error') {
body = (
<span className={cn(kind === 'error' && `${sid}-shake`, 'relative block size-full')}>
<span className={`${sid}-pop absolute inset-[10%] rounded-full`} style={{ background: `color-mix(in oklab, ${tone} 14%, transparent)` }} />
{kind === 'success' && (
<svg viewBox="0 0 64 64" className={`${sid}-burst absolute inset-0 size-full opacity-0`} aria-hidden>
{Array.from({ length: 8 }, (_, i) => {
const a = (i / 8) * Math.PI * 2;
return <line key={i} x1={round(32 + Math.cos(a) * 26)} y1={round(32 + Math.sin(a) * 26)} x2={round(32 + Math.cos(a) * 30.5)} y2={round(32 + Math.sin(a) * 30.5)} {...stroke} strokeWidth="2.4" />;
})}
</svg>
)}
<svg viewBox="0 0 64 64" className="absolute inset-0 size-full" aria-hidden>
<circle className={`${sid}-ring`} cx="32" cy="32" r="21" pathLength={1} strokeDasharray="1 1" transform="rotate(-90 32 32)" {...stroke} strokeWidth="2.6" />
{kind === 'success' ? (
<path className={`${sid}-mark`} d="M23.5 32.5 29.5 38.5 41 26" pathLength={1} strokeDasharray="1 1" {...stroke} strokeWidth="3.2" />
) : (
<>
<path className={`${sid}-mark`} d="M25 25 39 39" pathLength={1} strokeDasharray="1 1" {...stroke} strokeWidth="3.2" />
<path className={`${sid}-mark2`} d="M39 25 25 39" pathLength={1} strokeDasharray="1 1" {...stroke} strokeWidth="3.2" />
</>
)}
</svg>
</span>
);
} else if (kind === 'warning') {
body = (
<span className="relative block size-full">
<span className={`${sid}-pop absolute inset-[12%] rounded-[30%]`} style={{ background: `color-mix(in oklab, ${tone} 16%, transparent)` }} />
<svg viewBox="0 0 64 64" className="absolute inset-0 size-full" aria-hidden>
<path className={`${sid}-ring`} d="M32 17.5 48.5 46H15.5Z" pathLength={1} strokeDasharray="1 1" {...stroke} strokeWidth="2.6" />
<g className={`${sid}-drop`}>
<path d="M32 28v8" {...stroke} strokeWidth="3" />
<circle cx="32" cy="40.8" r="1.8" fill={tone} />
</g>
</svg>
</span>
);
} else if (kind === 'loading') {
if (spinner === 'dots')
body = (
<span className="flex size-full items-center justify-center gap-[12%]">
{[0, 1, 2].map((i) => <span key={i} className={`${sid}-dot block size-[18%] rounded-full`} style={{ background: tone, '--d': `${round((i * 0.16) / sp, 2)}s` } as React.CSSProperties} />)}
</span>
);
else if (spinner === 'bars')
body = (
<span className="flex size-full items-center justify-center gap-[8%]">
{[0, 1, 2, 3].map((i) => <span key={i} className={`${sid}-bar block h-[52%] w-[10%] rounded-full`} style={{ background: tone, '--d': `${round((i * 0.12) / sp, 2)}s`, transform: `scaleY(${[0.55, 0.85, 1, 0.7][i]})` } as React.CSSProperties} />)}
</span>
);
else
body = (
<svg viewBox="0 0 64 64" className="size-full" aria-hidden>
<circle cx="32" cy="32" r="22" fill="none" stroke={`color-mix(in oklab, ${tone} 16%, transparent)`} strokeWidth="5" />
<g className={`${sid}-spin`} style={{ transformOrigin: '32px 32px' }}>
<circle cx="32" cy="32" r="22" pathLength={100} strokeDasharray="28 72" transform="rotate(-90 32 32)" {...stroke} strokeWidth="5" />
</g>
</svg>
);
} else if (kind === 'progress') {
const v = Math.min(1, Math.max(0, value));
body = (
<span className="relative grid size-full place-items-center">
<svg viewBox="0 0 64 64" className="absolute inset-0 size-full -rotate-90" aria-hidden>
<circle cx="32" cy="32" r="26" fill="none" stroke={`color-mix(in oklab, ${tone} 16%, transparent)`} strokeWidth="5" />
<circle className={`${sid}-fill`} cx="32" cy="32" r="26" pathLength={1} strokeDasharray="1 1" strokeDashoffset={round(1 - v, 4)} {...stroke} strokeWidth="5" />
</svg>
<span className="relative font-semibold tabular-nums tracking-[-0.02em]" style={{ fontSize: round(size * 0.22) }}>{children}</span>
</span>
);
} else {
body = (
<span className="relative grid size-full place-items-center">
{[0, 1].map((i) => <span key={i} className={`${sid}-ripple absolute size-[34%] rounded-full opacity-0`} style={{ background: tone, '--d': `${round((i * 1.2) / sp, 2)}s` } as React.CSSProperties} />)}
<span className="relative size-[34%] rounded-full shadow-[0_0_0_3px_color-mix(in_oklab,var(--background)_70%,transparent)]" style={{ background: tone }} />
</span>
);
}
return (
<span
ref={play.ref}
data-kv={play.state}
role={label ? (kind === 'loading' || kind === 'progress' ? 'status' : 'img') : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn(sid, 'relative inline-block shrink-0 align-middle', className)}
style={{ width: size, height: size }}
>
<style dangerouslySetInnerHTML={{ __html: css }} />
<span key={play.cycle} className="block size-full">
{body}
</span>
</span>
);
}Häufige Fragen
Wie installiere ich Status-Animationen?
Mit aiw add @ai-website/status-animation. Der Befehl legt die Datei components/ui/status-animation.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind motion-scene. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Status-Animationen?
6 Varianten, Erfolg, Fehler und Warnung, Laden, Fortschrittsring, Live-Puls und Knopf-Zustände.
Kann ein KI-Assistent Status-Animationen einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Status-Animationen mit aiw_components und installieren sie mit aiw_add @ai-website/status-animation.