Skip to content
UI Kit
Accent color

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

2 variants

Variants

States

Idle, loading with or without text, success and error. The spinner closes into a drawn check, errors say what to do next.

With a promise

onClick returns a promise, the button follows it on its own. The width glides between the texts, a failure shakes once. Click to try both outcomes.

Details

What Loading button brings along, read from the registry and the source.

Category
Buttons
Variants
2
Version
1.0.0 · since 1.0.0
File
components/ui/loading-button.tsx
Brings along
utilskitbuttonicon
npm packages
None
Languages
English and German, 3 texts of its own
Studio
Adjust in code

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'>.

  • variantoptional
    ButtonVariant
  • sizeoptional
    ButtonSize
  • iconoptional
    IconName
  • iconLeftoptional
    IconName
  • stateoptional
    LoadingState

    idleloadingsuccesserror

    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

  • loadingTextoptional
    string

    Text while working, e.g. "Saving". Without it the label stays and the spinner sits in its place.

  • successTextoptional
    string
  • errorTextoptional
    string
  • resetAfteroptional
    number

    How long success or error stay before the button returns to idle, ms

  • magneticoptional
    boolean

    Pulls 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
loadingEnglish WorkingGerman Läuft
successEnglish DoneGerman Erledigt
errorEnglish Did not work, try againGerman 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
components/ui/loading-button.tsx
'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.

Often searched

Jump to

Copied to the clipboard