Loading button
A button that shows it is working, keeps its width, blocks double clicks and reports success or failure.
One of 6 components in Buttons
Details
What Loading button brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 2
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/loading-button.tsx
- npm packages
- None
- Languages
- English and German, 3 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The LoadingButtonProps interface from components/ui/loading-button.tsx, with the comments from the code. Also takes every prop of Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onClick'>.
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
Built-in texts
Loading button writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 3 texts
| Key | English | German |
|---|---|---|
| loading | English Working | German Läuft |
| success | English Done | German Erledigt |
| error | English Did not work, try again | German Hat nicht geklappt, bitte erneut versuchen |
Accessibility
Read from the source of the component.
- Sets aria-busy, aria-disabled, aria-label and aria-live, so screen readers announce purpose and state.
- Uses the role label.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- Rises in briefly the first time it comes into view (kit-rise, useInView).
- Brings animations of its own, calmly timed and without wobble.
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 183 lines
'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>
);
}Frequent questions
How do I install Loading button?
With aiw add @ai-website/loading-button. The command puts components/ui/loading-button.tsx into your project, the code is yours from then on. It brings along button and icon. It needs no further npm packages.
Which variants does Loading button have?
2 variants, States and With a promise.
Can I translate the texts of Loading button?
Yes. Loading button carries 3 texts of its own in English and German, such as “Working”, “Done” and “Did not work, try again”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Loading button?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Loading button with aiw_components and install it with aiw_add @ai-website/loading-button.