Skip to content
UI Kit
Accent color

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

6 variants

Variants

Success

The circle draws, the check follows, a small burst closes it. Plays once when it scrolls in.

Error and warning

The cross draws in two strokes and shakes once. The warning sign drops its mark in with a soft bounce.

Loading

A ring with an eased turn, three breathing dots and four bars. They stand still with reduced motion.

Progress ring

Rings fill to their value when they appear, the number counts along.

Live pulse

A dot with two soft rings for things that run right now. Calm, never in step with the next one.

Button states

A real button that walks through loading and success, then returns. The text changes are announced.

Details

What Status animations brings along, read from the registry and the source.

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

Props

The StatusAnimationProps interface from components/ui/status-animation.tsx, with the comments from the code.

  • kindrequired
    StatusKind

    successerrorwarningloadingprogresspulse

  • sizeoptional
    number

    Size in px. Default 64.

  • spinneroptional
    'ring' | 'dots' | 'bars'

    loading only: ring (default), dots or bars

  • valueoptional
    number

    progress only: 0 to 1

  • loopoptional
    boolean

    Replay the symbol every few seconds instead of once. Default false (loading and pulse always run).

  • autoPlayoptional
    AutoPlay

    When it plays. Default in-view.

  • speedoptional
    number

    1 is the designed tempo

  • labeloptional
    string

    Read to screen readers, e.g. "Payment received". Without it the symbol is decorative.

  • childrenoptional
    React.ReactNode

    progress 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
components/ui/status-animation.tsx
'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.

Often searched

Jump to

Copied to the clipboard