Zum Inhalt springen
UI Kit
Accent

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

6 Variants

Variants

Erfolg

Der Kreis zeichnet sich, der Haken folgt, ein kleiner Kranz schließt ab. Spielt einmal beim Hereinscrollen.

Fehler und Warnung

Das Kreuz zeichnet sich in zwei Strichen und wackelt einmal. Das Warnzeichen lässt sein Ausrufezeichen weich hineinfallen.

Laden

Ein Ring mit weicher Drehung, drei atmende Punkte und vier Balken. Bei reduzierter Bewegung stehen sie still.

Fortschrittsring

Ringe füllen sich beim Erscheinen auf ihren Wert, die Zahl zählt mit.

Live-Puls

Ein Punkt mit zwei weichen Ringen für das, was gerade läuft. Ruhig, nie im Gleichschritt mit dem nächsten.

Knopf-Zustände

Ein echter Knopf, der durch Laden und Erfolg läuft und zurückkehrt. Die Textwechsel werden vorgelesen.

Details

Was Status-Animationen mitbringt, aus Registry und Quellcode gelesen.

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

Props

Die Schnittstelle StatusAnimationProps aus components/ui/status-animation.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • kindPflicht
    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

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
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>
  );
}

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.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert