Ladeknopf
Ein Knopf, der zeigt, dass er arbeitet, seine Breite hält, Doppelklicks blockt und Erfolg oder Fehler meldet.
Eine von 6 Components in Buttons
Details
Was Ladeknopf mitbringt, aus Registry und Quellcode gelesen.
- Kategorie
- Buttons
- Bereich
- Elements
- Variants
- 2
- Version
- 1.0.0 · seit 1.0.0
- Datei
- components/ui/loading-button.tsx
- npm-Pakete
- Keine
- Sprachen
- Englisch und Deutsch, 3 eigene Texte
- Studio
- Im Code anpassen
- Mit KI bauen
- Per MCP in Claude, Cursor, Copilot und Codex
Props
Die Schnittstelle LoadingButtonProps aus components/ui/loading-button.tsx. Beschreibungen stammen aus den Kommentaren im Code und sind auf Englisch. Nimmt außerdem alle Props von Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> an.
variantoptionalButtonVariantsizeoptionalButtonSizeiconoptionalIconNameiconLeftoptionalIconNamestateoptionalLoadingStateidleloadingsuccesserror
Controlled state. Without it the button runs onClick and follows its promise.
onClickoptional(e: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>May return a promise: the button shows the spinner until it settles, then success or error
loadingTextoptionalstringText while working, e.g. "Saving". Without it the label stays and the spinner sits in its place.
successTextoptionalstringerrorTextoptionalstringresetAfteroptionalnumberHow long success or error stay before the button returns to idle, ms
magneticoptionalbooleanPulls the button a few pixels toward the mouse
Eingebaute Texte
Diese Texte schreibt Ladeknopf selbst. Die Sprache folgt dem KitProvider, über die Prop labels ersetzt du einzelne.
Alle 3 Texte anzeigen
| Schlüssel | Englisch | Deutsch |
|---|---|---|
| loading | Englisch Working | Deutsch Läuft |
| success | Englisch Done | Deutsch Erledigt |
| error | Englisch Did not work, try again | Deutsch Hat nicht geklappt, bitte erneut versuchen |
Barrierefreiheit
Aus dem Quellcode der Component abgeleitet.
- Setzt aria-busy, aria-disabled, aria-label und aria-live, damit Screenreader Zweck und Zustand ansagen.
- Nutzt die Rolle label.
- Meldet Änderungen über eine Live-Region, ohne den Fokus zu verschieben.
- Ergänzt Text für Screenreader, wo Icons und Grafiken allein stehen.
- Der Server rendert den Startzustand vollständig, JavaScript ergänzt nur die Interaktion.
Bewegung
- Erscheint beim ersten Sichtkontakt mit einem kurzen Auftritt (kit-rise, useInView).
- Bringt eigene Animationen mit, ruhig getaktet und ohne Wackeln.
- Bei reduzierter Bewegung (prefers-reduced-motion) bleibt sie ruhig. 2 Stellen im Code schalten Bewegung dann ab.
Quellcode
Die Datei, die aiw add in dein Projekt legt. Sie gehört danach dir.
Datei anzeigen, 183 Zeilen
'use client';
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Button, type ButtonSize, type ButtonVariant } from '@/components/ui/button';
import { Icon, type IconName } from '@/components/ui/icon';
const TEXTS = {
en: { loading: 'Working', success: 'Done', error: 'Did not work, try again' },
de: { loading: 'Läuft', success: 'Erledigt', error: 'Hat nicht geklappt, bitte erneut versuchen' },
};
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';
// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
const EASE = 'cubic-bezier(0.2,0.7,0.2,1)';
/** The kit's spinner: a quiet ring with a lit quarter. */
export function Spinner({ size = 16, className, label }: { size?: number; className?: string; label?: string }) {
return (
<svg
viewBox="0 0 24 24"
width={size}
height={size}
fill="none"
role={label ? 'status' : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className={cn('shrink-0 animate-kit-spin [--kit-duration:0.8s] motion-reduce:animate-[kit-spin_2.4s_linear_infinite]', className)}
>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.22" strokeWidth="2.4" />
<path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</svg>
);
}
/**
* The spinner that turns into a check. While loading the lit arc runs round,
* on success it closes to a full ring and fades while the check is drawn.
* The same element stays mounted, so the change reads as one movement.
*/
function StatusMark({ state, size }: { state: 'loading' | 'success'; size: number }) {
const ring = useRef<SVGCircleElement>(null);
const arcs = useRef<SVGGElement>(null);
const check = useRef<SVGPathElement>(null);
const first = useRef(true);
useEffect(() => {
const wasFirst = first.current;
first.current = false;
if (!check.current?.animate) return;
// Back to loading: drop the held end frames of the last success
for (const el of [ring.current, arcs.current, check.current]) el?.getAnimations().forEach((a) => a.cancel());
if (state !== 'success' || prefersReducedMotion()) return;
// Arc closes and fades, the check draws itself, then a small settle
ring.current?.animate([{ strokeDashoffset: wasFirst ? 0 : 0.72 }, { strokeDashoffset: 0 }], { duration: 320, easing: EASE });
arcs.current?.animate([{ opacity: 1 }, { opacity: 1, offset: 0.4 }, { opacity: 0 }], { duration: 520, easing: EASE, fill: 'both' });
check.current.animate([{ strokeDashoffset: 1 }, { strokeDashoffset: 1, offset: 0.3 }, { strokeDashoffset: 0 }], { duration: 620, easing: EASE, fill: 'both' });
}, [state]);
const done = state === 'success';
return (
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" aria-hidden className="shrink-0">
<g ref={arcs} className={cn('origin-center [transform-box:fill-box] animate-kit-spin [--kit-duration:0.8s] motion-reduce:animate-[kit-spin_2.4s_linear_infinite]', done && 'opacity-0')}>
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.22" strokeWidth="2.4" />
<circle ref={ring} cx="12" cy="12" r="9" pathLength={1} strokeDasharray="1 1" strokeDashoffset={done ? 0 : 0.72} stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" />
</g>
<path ref={check} d="m5 12.5 4.5 4.5L19 7.5" pathLength={1} strokeDasharray="1 1" strokeDashoffset={done ? 0 : 1} stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
export interface LoadingButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'> {
variant?: ButtonVariant;
size?: ButtonSize;
icon?: IconName;
iconLeft?: IconName;
/** Controlled state. Without it the button runs onClick and follows its promise. */
state?: LoadingState;
/** May return a promise: the button shows the spinner until it settles, then success or error */
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void | Promise<unknown>;
/** Text while working, e.g. "Saving". Without it the label stays and the spinner sits in its place. */
loadingText?: string;
successText?: string;
errorText?: string;
/** How long success or error stay before the button returns to idle, ms */
resetAfter?: number;
/** Pulls the button a few pixels toward the mouse */
magnetic?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
}
/**
* A button that shows it is working. Blocks double clicks, sets aria-busy and
* announces the result politely. The spinner turns into a drawn check, the
* width glides between the texts of each state, an error shakes once.
* <LoadingButton onClick={() => save()} loadingText="Saving" successText="Saved">Save changes</LoadingButton>
*/
export function LoadingButton({ state: stateProp, onClick, loadingText, successText, errorText, resetAfter = 2000, labels, variant, size = 'md', icon, iconLeft, className, children, disabled, ...rest }: LoadingButtonProps) {
const t = useTexts(TEXTS, labels);
const [inner, setInner] = useState<LoadingState>('idle');
const state = stateProp ?? inner;
const timer = useRef<number | undefined>(undefined);
const ref = useRef<HTMLButtonElement>(null);
const width = useRef(0);
const lastState = useRef(state);
useEffect(() => () => window.clearTimeout(timer.current), []);
// The width glides from the old text to the new one instead of jumping
useIsoLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const next = el.getBoundingClientRect().width;
const prev = width.current;
width.current = next;
const changed = lastState.current !== state;
const from = lastState.current;
lastState.current = state;
if (!changed || prefersReducedMotion() || typeof el.animate !== 'function') return;
if (prev && Math.abs(prev - next) > 1) el.animate([{ width: `${prev}px` }, { width: `${next}px` }], { duration: 360, easing: EASE });
if (state === 'error' && from !== 'error')
el.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-6px)' }, { transform: 'translateX(5px)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(2px)' }, { transform: 'translateX(0)' }], { duration: 420, easing: 'ease-out' });
}, [state, loadingText, successText, errorText]);
const handle = async (e: React.MouseEvent<HTMLButtonElement>) => {
if (state === 'loading') return;
const result = onClick?.(e);
if (stateProp || !(result instanceof Promise)) return;
setInner('loading');
try {
await result;
setInner('success');
} catch {
setInner('error');
}
window.clearTimeout(timer.current);
timer.current = window.setTimeout(() => setInner('idle'), resetAfter);
};
const iconSize = size === 'sm' ? 15 : 17;
// Without a loading text the spinner sits on top of the hidden label, and turns into the check right there
const overlay = !loadingText && (state === 'loading' || (state === 'success' && !successText));
const text =
state === 'loading' && loadingText ? loadingText : state === 'success' && successText ? successText : state === 'error' && errorText ? errorText : children;
const lead =
(state === 'loading' && loadingText) || (state === 'success' && !overlay) ? (
<StatusMark state={state === 'success' ? 'success' : 'loading'} size={iconSize} />
) : state === 'error' ? (
<Icon name="alert-circle" size={iconSize} className="kit-rise [animation-duration:.35s]" />
) : iconLeft ? (
<Icon name={iconLeft} size={iconSize} />
) : null;
return (
<Button
{...rest}
ref={ref}
variant={variant}
size={size}
icon={state === 'idle' ? icon : undefined}
disabled={disabled}
aria-busy={state === 'loading' || undefined}
aria-disabled={state === 'loading' || undefined}
onClick={handle}
className={cn(
'relative overflow-hidden',
state === 'loading' && 'cursor-progress',
state === 'error' && variant !== 'ghost' && variant !== 'link' && 'bg-destructive text-destructive-foreground hover:bg-[color-mix(in_oklab,var(--destructive)_88%,var(--background))] border-transparent',
className,
)}
>
{lead}
<span className={cn('transition-opacity duration-200', overlay && 'opacity-0')}>{text}</span>
{overlay && (
<span className="absolute inset-0 grid place-items-center">
<StatusMark state={state === 'success' ? 'success' : 'loading'} size={iconSize + 1} />
</span>
)}
<span aria-live="polite" className="sr-only">
{state === 'loading' ? loadingText ?? t.loading : state === 'success' ? successText ?? t.success : state === 'error' ? errorText ?? t.error : ''}
</span>
</Button>
);
}Häufige Fragen
Wie installiere ich Ladeknopf?
Mit aiw add @ai-website/loading-button. Der Befehl legt die Datei components/ui/loading-button.tsx in dein Projekt, der Code gehört danach dir. Mit dabei sind button und icon. Weitere npm-Pakete braucht sie nicht.
Welche Varianten hat Ladeknopf?
2 Varianten, Zustände und Mit Promise.
Kann ich die Texte von Ladeknopf übersetzen?
Ja. Ladeknopf bringt 3 eigene Texte auf Deutsch und Englisch mit, etwa „Läuft“, „Erledigt“ und „Hat nicht geklappt, bitte erneut versuchen“. Die Sprache folgt dem KitProvider, einzelne Texte ersetzt du über die Prop labels.
Kann ein KI-Assistent Ladeknopf einbauen?
Ja. Über den MCP-Server der aiw CLI finden Claude, Cursor, GitHub Copilot, Windsurf, Codex und Gemini Ladeknopf mit aiw_components und installieren sie mit aiw_add @ai-website/loading-button.