Zum Inhalt springen
UI Kit
Accent

Lade-Spinner

Sechs gezeichnete Lader auf einem Raster, in fünf Größen und drei Tönen, in Knöpfen und Feldern, mit Statustext für Screenreader und einem Ring, der sich mit dem Prozentwert füllt.

5 VariantsEine von 5 Components in Fortschritt und Zustände

Arbeit im Gang, genau dort, wo sie passiert

Ein Spinner markiert laufende Arbeit genau dort, wo sie passiert, in einem Knopf, einem Feld oder einer Fläche. Anders als Fortschrittsbalken oder Skeleton sagt er, dass gerade hier etwas arbeitet, und jeder Lader wird Screenreadern als Live-Status angesagt.

„Sechs Lader“ zeigt Ring, Punkte, Balken, Puls, Orbit und Speichen, jeder mit der Aufgabe, zu der er passt, bei reduzierter Bewegung steht jeder still als klares Bild. „Größen und Töne“ deckt fünf Größen von 14 bis 48 px ab, die Strichstärke folgt der Größe, in Textfarbe, Akzent oder ruhig. „In Knöpfen und Feldern“ zeigt einen Knopf, der beim Arbeiten sagt, was er tut, und seine Breite hält, ein Suchfeld, das sucht, und einen kleinen Abgleich-Hinweis. Die „Statusfläche“ setzt den Lader über seine Nachricht für längere Arbeit, der aktuelle Schritt wird höflich angesagt und eine Liste zeigt, was erledigt ist. Der „Fortschrittsring“ füllt sich mit dem Prozentwert, sobald die Menge bekannt ist.

Zeig einen Spinner erst nach einer kurzen Verzögerung, schnelle Aktionen sollen nicht flackern. Sag in Worten, was passiert, speichern oder suchen, ein bloßer Kreis erklärt nichts. Wechsle zum Fortschrittsring, sobald du weißt, wie viel noch fehlt.

Variants

Sechs LaderNeu in 1.2

Ring, Punkte, Balken, Puls, Orbit und Speichen, jeder mit der Aufgabe, zu der er passt. Bei reduzierter Bewegung steht jeder still als klares Bild.

Größen und TöneNeu in 1.2

Fünf Größen von 14 bis 48 px, die Strichstärke folgt der Größe. In Textfarbe, Akzent oder ruhig, auch mitten im Satz.

In Knöpfen und FeldernNeu in 1.2

Ein Knopf, der beim Arbeiten sagt, was er tut, und seine Breite hält, ein Suchfeld, das sucht, ein kleiner Abgleich-Hinweis und ein Symbolknopf. Drück auf Sichern.

StatusflächeNeu in 1.2

Für Arbeit, die etwas dauert. Der Lader steht über seiner Nachricht, der aktuelle Schritt wird höflich angesagt, die Liste daneben zeigt, was erledigt ist.

FortschrittsringNeu in 1.2

Ist die Menge bekannt, füllt sich der Ring und schreibt den Prozentwert in die Mitte. Bis dahin läuft er wie ein Spinner. Eine Fortschrittsanzeige mit Wert für Screenreader.

Details

Was Spinner mitbringt, aus Registry und Quellcode gelesen.

Bereich
Elements
Variants
5
Version
1.0.0 · seit 1.2.0
Datei
components/ui/spinner.tsx
Bringt mit
utilskit
npm-Pakete
Keine
Sprachen
Englisch und Deutsch, 2 eigene Texte
Studio
Im Code anpassen

Props

Die Schnittstelle SpinnerProps aus components/ui/spinner.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.

  • typeoptional
    SpinnerType

    ringdotsbarspulseorbitspokes

  • sizeoptional
    SpinnerSize

    xssmmdlgxl

  • toneoptional
    SpinnerTone

    currentaccentmuted

  • labeloptional
    string

    What is happening, e.g. "Saving draft". Read out by screen readers, shown when showLabel is set.

  • showLabeloptional
    boolean

    Write the label next to the loader

  • stackedoptional
    boolean

    Label below the loader instead of beside it, for empty panels

  • announceoptional
    boolean

    Announce the loader as a live status. Turn it off inside a button or a region that already says it is busy (aria-busy), so nothing is read twice.

Eingebaute Texte

Diese Texte schreibt Spinner selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.

Alle 2 Texte anzeigen
loadingEnglisch LoadingDeutsch Wird geladen
progressEnglisch ProgressDeutsch Fortschritt

Barrierefreiheit

Aus dem Quellcode der Component abgeleitet.

  • Setzt aria-label, aria-live, aria-valuemax, aria-valuemin, aria-valuenow und aria-valuetext, damit Screenreader Zweck und Zustand ansagen.
  • Nutzt die Rollen announce, progressbar und status.
  • Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
  • Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
  • Kommt ohne Client-State aus. Der Server schreibt sie vollständig, sie ist auch ohne JavaScript lesbar.

Bewegung

  • Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
  • Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 1 Stellen im Code schalten Bewegung dann ab.

Quellcode

Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.

Datei anzeigen, 226 Zeilen
components/ui/spinner.tsx
'use client';

import { cn, formatPercent, round, useLocale, useTexts } from '@/lib/kit';

/*
 * Spinners: small signs that something is working. Six drawn loaders in one
 * family, every one built on the same 24 px grid so they swap without
 * shifting the layout. With reduced motion each one stands still as a
 * readable picture (an open ring, three dots in rising strength, bars of
 * different height) and the label carries the message.
 */

export type SpinnerType = 'ring' | 'dots' | 'bars' | 'pulse' | 'orbit' | 'spokes';
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SpinnerTone = 'current' | 'accent' | 'muted';

const PX: Record<SpinnerSize, number> = { xs: 14, sm: 18, md: 24, lg: 36, xl: 48 };
const TEXT: Record<SpinnerSize, string> = { xs: 'text-[12px]', sm: 'text-[13px]', md: 'text-sm', lg: 'text-[15px]', xl: 'text-base' };
const TONE: Record<SpinnerTone, string> = {
  current: 'text-current',
  accent: 'text-primary',
  muted: 'text-muted-foreground',
};

const TEXTS = {
  en: { loading: 'Loading', progress: 'Progress' },
  de: { loading: 'Wird geladen', progress: 'Fortschritt' },
};
type SpinnerTexts = (typeof TEXTS)['en'];

/* All motion lives in one style sheet, keyed by the kitfx-sp prefix. Reduced motion stops every loop. */
const STYLES =
  '.kitfx-sp-spin{animation:kitfx-sp-spin .85s linear infinite}' +
  '.kitfx-sp-steps{animation:kitfx-sp-spin .9s steps(8) infinite}' +
  '.kitfx-sp-orbit{animation:kitfx-sp-spin 1.3s cubic-bezier(.45,.05,.55,.95) infinite}' +
  '@keyframes kitfx-sp-spin{to{transform:rotate(360deg)}}' +
  '.kitfx-sp-dot{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-dot 1.05s ease-in-out infinite;animation-delay:calc(var(--i) * 150ms)}' +
  '@keyframes kitfx-sp-dot{0%,80%,100%{opacity:.3;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}}' +
  '.kitfx-sp-bar{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-bar 1s ease-in-out infinite;animation-delay:calc(var(--i) * 120ms)}' +
  '@keyframes kitfx-sp-bar{0%,70%,100%{transform:scaleY(.4);opacity:.45}35%{transform:scaleY(1);opacity:1}}' +
  '.kitfx-sp-wave{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-wave 1.6s cubic-bezier(.2,.7,.2,1) infinite;animation-delay:calc(var(--i) * 800ms)}' +
  '@keyframes kitfx-sp-wave{0%{transform:scale(.45);opacity:.7}100%{transform:scale(1);opacity:0}}' +
  '.kitfx-sp-core{transform-box:fill-box;transform-origin:center;animation:kitfx-sp-core 1.6s ease-in-out infinite}' +
  '@keyframes kitfx-sp-core{50%{transform:scale(.82)}}' +
  '@media (prefers-reduced-motion:reduce){.kitfx-sp-spin,.kitfx-sp-steps,.kitfx-sp-orbit,.kitfx-sp-dot,.kitfx-sp-bar,.kitfx-sp-core{animation:none}.kitfx-sp-wave{animation:none;opacity:.35;transform:scale(.8)}.kitfx-sp-wave[style*="--i:1"]{opacity:0}}';

function Styles() {
  return (
    <style href="kitfx-sp" precedence="default">
      {STYLES}
    </style>
  );
}

/** The drawing of one loader, 24 by 24, in currentColor */
function Drawing({ type, px }: { type: SpinnerType; px: number }) {
  const common = { width: px, height: px, viewBox: '0 0 24 24', fill: 'none', 'aria-hidden': true as const, className: 'block shrink-0 overflow-visible' };
  // Thin strokes look heavy at small sizes and faint at large ones, so the stroke follows the size
  const sw = px <= 18 ? 2.6 : px >= 40 ? 1.8 : 2.2;
  switch (type) {
    case 'ring':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-spin')}>
          <circle cx="12" cy="12" r="9.5" stroke="currentColor" strokeOpacity="0.18" strokeWidth={sw} />
          <path d="M12 2.5a9.5 9.5 0 0 1 9.5 9.5" stroke="currentColor" strokeWidth={sw} strokeLinecap="round" />
        </svg>
      );
    case 'dots':
      return (
        <svg {...common}>
          {[5, 12, 19].map((cx, i) => (
            <circle key={cx} cx={cx} cy="12" r="2.4" fill="currentColor" className="kitfx-sp-dot" style={{ '--i': i, opacity: [0.35, 0.65, 1][i] } as React.CSSProperties} />
          ))}
        </svg>
      );
    case 'bars':
      return (
        <svg {...common}>
          {[3.5, 8.5, 13.5, 18.5].map((x, i) => {
            const h = [10, 16, 13, 7][i];
            return <rect key={x} x={x - 0.5} y={12 - h / 2} width="3" height={h} rx="1.5" fill="currentColor" className="kitfx-sp-bar" style={{ '--i': i, transform: 'none' } as React.CSSProperties} />;
          })}
        </svg>
      );
    case 'pulse':
      return (
        <svg {...common}>
          {[0, 1].map((i) => (
            <circle key={i} cx="12" cy="12" r="10.5" stroke="currentColor" strokeWidth={px <= 18 ? 2 : 1.6} className="kitfx-sp-wave" style={{ '--i': i } as React.CSSProperties} />
          ))}
          <circle cx="12" cy="12" r="4.2" fill="currentColor" className="kitfx-sp-core" />
        </svg>
      );
    case 'orbit':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-orbit')}>
          <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.18" strokeWidth={sw * 0.8} />
          <circle cx="12" cy="3" r="2.6" fill="currentColor" />
          <circle cx={round(12 + 9 * Math.sin(-0.62))} cy={round(12 - 9 * Math.cos(-0.62))} r="1.7" fill="currentColor" opacity="0.5" />
          <circle cx={round(12 + 9 * Math.sin(-1.12))} cy={round(12 - 9 * Math.cos(-1.12))} r="1.1" fill="currentColor" opacity="0.25" />
        </svg>
      );
    case 'spokes':
      return (
        <svg {...common} className={cn(common.className, 'kitfx-sp-steps')}>
          {Array.from({ length: 8 }, (_, i) => {
            const a = (i * Math.PI) / 4;
            return (
              <line
                key={i}
                x1={round(12 + 5 * Math.sin(a))}
                y1={round(12 - 5 * Math.cos(a))}
                x2={round(12 + 9.5 * Math.sin(a))}
                y2={round(12 - 9.5 * Math.cos(a))}
                stroke="currentColor"
                strokeWidth={sw}
                strokeLinecap="round"
                opacity={round(1 - ((8 - i) % 8) * 0.105, 3)}
              />
            );
          })}
        </svg>
      );
  }
}

export interface SpinnerProps {
  type?: SpinnerType;
  size?: SpinnerSize;
  tone?: SpinnerTone;
  /** What is happening, e.g. "Saving draft". Read out by screen readers, shown when showLabel is set. */
  label?: string;
  /** Write the label next to the loader */
  showLabel?: boolean;
  /** Label below the loader instead of beside it, for empty panels */
  stacked?: boolean;
  /**
   * Announce the loader as a live status. Turn it off inside a button or a
   * region that already says it is busy (aria-busy), so nothing is read twice.
   */
  announce?: boolean;
  labels?: Partial<SpinnerTexts>;
  className?: string;
}

/**
 * A loader that shows that something is working, with a label for screen readers (role="status").
 *   <Spinner type="dots" label="Saving draft" showLabel />
 */
export function Spinner({ type = 'ring', size = 'md', tone = 'current', label, showLabel, stacked, announce = true, labels, className }: SpinnerProps) {
  const t = useTexts(TEXTS, labels);
  const text = label ?? t.loading;
  return (
    <span
      role={announce ? 'status' : undefined}
      aria-live={announce ? 'polite' : undefined}
      className={cn('inline-flex items-center', stacked ? 'flex-col gap-3 text-center' : 'gap-2.5', TONE[tone], className)}
    >
      <Styles />
      <Drawing type={type} px={PX[size]} />
      <span className={cn(showLabel ? cn('font-medium text-muted-foreground', TEXT[size]) : 'sr-only')}>{text}</span>
    </span>
  );
}

export interface ProgressSpinnerProps {
  /** 0 to 100 */
  value: number;
  /** Diameter in px, 32 to 120 */
  size?: number;
  tone?: Exclude<SpinnerTone, 'current'>;
  /** Write the percentage in the middle (from 40 px on) */
  showValue?: boolean;
  /** Accessible name, e.g. "Upload of plan.pdf" */
  label?: string;
  /** Turn into a running ring while the total is still unknown */
  indeterminate?: boolean;
  labels?: Partial<SpinnerTexts>;
  className?: string;
}

/**
 * A ring that fills with the progress and writes the percentage in its middle.
 * Before the total is known it runs like a spinner (indeterminate), then it fills.
 */
export function ProgressSpinner({ value, size = 56, tone = 'accent', showValue = true, label, indeterminate, labels, className }: ProgressSpinnerProps) {
  const t = useTexts(TEXTS, labels);
  const locale = useLocale();
  const v = Math.min(100, Math.max(0, value));
  const stroke = size >= 72 ? 5 : size >= 48 ? 4 : 3;
  const r = (size - stroke) / 2;
  const c = 2 * Math.PI * r;
  return (
    <span
      role="progressbar"
      aria-label={label ?? t.progress}
      aria-valuemin={0}
      aria-valuemax={100}
      aria-valuenow={indeterminate ? undefined : round(v, 0)}
      aria-valuetext={indeterminate ? t.loading : formatPercent(round(v, 0), locale)}
      className={cn('relative inline-grid shrink-0 place-items-center', className)}
      style={{ width: size, height: size }}
    >
      <Styles />
      <svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} fill="none" aria-hidden className={cn('absolute inset-0 -rotate-90', indeterminate && 'kitfx-sp-spin')}>
        <circle cx={size / 2} cy={size / 2} r={round(r)} stroke="var(--track)" strokeWidth={stroke} />
        <circle
          cx={size / 2}
          cy={size / 2}
          r={round(r)}
          stroke="currentColor"
          strokeWidth={stroke}
          strokeLinecap="round"
          strokeDasharray={round(c)}
          strokeDashoffset={round(indeterminate ? c * 0.72 : c * (1 - v / 100))}
          className={cn(TONE[tone], 'transition-[stroke-dashoffset] duration-500 ease-[var(--ease-kit)] motion-reduce:transition-none')}
        />
      </svg>
      {showValue && size >= 40 && !indeterminate && (
        <span className={cn('relative font-semibold tabular-nums tracking-[-0.02em] text-foreground', size >= 88 ? 'text-xl' : size >= 64 ? 'text-[15px]' : size >= 52 ? 'text-[12px]' : 'text-[10px] tracking-[-0.04em]')}>
          {formatPercent(round(v, 0), locale)}
        </span>
      )}
    </span>
  );
}

Häufige Fragen

Wie installiere ich Spinner?

Mit aiw add @ai-website/spinner. Der Befehl legt die Datei components/ui/spinner.tsx in dein Projekt, der Code gehört danach dir. Weitere npm-Pakete braucht sie nicht.

Welche Varianten hat Spinner?

5 Varianten, Sechs Lader, Größen und Töne, In Knöpfen und Feldern, Statusfläche und Fortschrittsring.

Kann ich die Texte von Spinner übersetzen?

Ja. Spinner bringt 2 eigene Texte auf Deutsch und Englisch mit, etwa „Wird geladen“ und „Fortschritt“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.

Kann ein KI-Assistent Spinner einbauen?

Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Spinner mit aiw_components und installieren sie mit aiw_add @ai-website/spinner.

Oft gesucht

Direkt zu

In die Zwischenablage kopiert