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.
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
Details
Was Spinner mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Fortschritt und Zustände
- 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
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle SpinnerProps aus components/ui/spinner.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch.
typeoptionalSpinnerTyperingdotsbarspulseorbitspokes
sizeoptionalSpinnerSizexssmmdlgxl
toneoptionalSpinnerTonecurrentaccentmuted
labeloptionalstringWhat is happening, e.g. "Saving draft". Read out by screen readers, shown when showLabel is set.
showLabeloptionalbooleanWrite the label next to the loader
stackedoptionalbooleanLabel below the loader instead of beside it, for empty panels
announceoptionalbooleanAnnounce 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
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| loading | Englisch Loading | Deutsch Wird geladen |
| progress | Englisch Progress | Deutsch 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
'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.