Status animations
Checks that draw themselves, an error that shakes once, three spinners, a filling ring and a live pulse. SVG and CSS only.
One of 4 components in Micro animations
Variants
Details
What Status animations brings along, read from the registry and the source.
- Category
- Micro animations
- Area
- Visuals
- Variants
- 6
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/status-animation.tsx
- Brings along
- utilskitmotion-scene
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The StatusAnimationProps interface from components/ui/status-animation.tsx, with the comments from the code.
kindrequiredStatusKindsuccesserrorwarningloadingprogresspulse
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
Accessibility
Read from the source of the component.
- Sets aria-label, so screen readers announce purpose and state.
- Uses the role label.
- Needs no client state. The server writes it in full, so it reads without JavaScript too.
Motion
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 214 lines
'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>
);
}Frequent questions
How do I install Status animations?
With aiw add @ai-website/status-animation. The command puts components/ui/status-animation.tsx into your project, the code is yours from then on. It brings along motion-scene. It needs no further npm packages.
Which variants does Status animations have?
6 variants, Success, Error and warning, Loading, Progress ring, Live pulse and Button states.
Can an AI assistant build with Status animations?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Status animations with aiw_components and install it with aiw_add @ai-website/status-animation.