# Loading Buttons

**MUST** · **ID:** `interactions-loading-buttons` · **Category:** interactions
**Source:** [Vercel](https://github.com/vercel-labs/agent-skills/blob/main/skills/web-design-guidelines/SKILL.md)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/interactions-loading-buttons

> MUST: Loading buttons show spinner and keep original label

Show a loading indicator and keep the original label

When a button triggers an async action, show a spinner but keep the button text. Changing the text can cause layout shifts and makes it harder for users to understand what's happening. The spinner provides clear feedback that work is in progress.

## Bad — do not do this

`interactions-loading-buttons-bad`

```tsx
import { useState } from 'react';

export function LoadingButtonsBad() {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = () => {
    setIsLoading(true);
    setTimeout(() => setIsLoading(false), 2000);
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="flex items-center gap-3">
        <button
          onClick={handleClick}
          disabled={isLoading}
          className="whitespace-nowrap rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-70"
        >
          {isLoading ? 'Saving your changes…' : 'Save'}
        </button>
        <span className="whitespace-nowrap text-sm text-muted-foreground">
          Draft saved 2m ago
        </span>
      </div>
      <p className="text-xs text-error">
        Swapping the label resizes the button, shoving the text beside it. Click and watch it jump.
      </p>
    </div>
  );
}
```

## Good — do this

`interactions-loading-buttons-good`

```tsx
import { useState } from 'react';
import { Loader2 } from 'lucide-react';

export function LoadingButtonsGood() {
  const [isLoading, setIsLoading] = useState(false);

  const handleClick = () => {
    setIsLoading(true);
    setTimeout(() => setIsLoading(false), 2000);
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <div className="flex items-center gap-3">
        <button
          onClick={handleClick}
          disabled={isLoading}
          className="inline-grid place-items-center whitespace-nowrap rounded-lg bg-primary px-4 py-2 text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-70"
        >
          {/* Idle layer — reserves width even while hidden */}
          <span
            className={`col-start-1 row-start-1 flex items-center gap-2 ${isLoading ? 'invisible' : ''}`}
          >
            Save
          </span>
          {/* Loading layer — reserves width even while hidden */}
          <span
            className={`col-start-1 row-start-1 flex items-center gap-2 ${isLoading ? '' : 'invisible'}`}
          >
            <Loader2 className="h-4 w-4 animate-spin" />
            Saving your changes…
          </span>
        </button>
        <span className="whitespace-nowrap text-sm text-muted-foreground">
          Draft saved 2m ago
        </span>
      </div>
      <p className="text-xs text-success">
        Both labels share one grid cell, so the button reserves the widest width — it and the text beside it never move.
      </p>
    </div>
  );
}
```

## References

- [Button Loading States](https://www.nngroup.com/articles/progress-indicators/)
