# Loading Button

An accessible React loading button for Next.js that shows loading, success and error states for async actions, without changing width.

Source: https://ui.fewtone.com/components/loading-button

## Installation

```bash
npx shadcn@latest add https://ui.fewtone.com/r/loading-button.json
```

## Usage

```tsx
import { LoadingButton } from "@/components/ui/loading-button"

export function SaveButton() {
  return (
    <LoadingButton
      onClick={() => saveChanges()}
      loadingText="Saving…"
      successText="Saved"
      errorText="Try again"
    >
      Save changes
    </LoadingButton>
  )
}
```

## API reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `onClick` | `(event) => unknown` | – | Return a promise to show loading until it settles, then success when it resolves or error when it rejects. |
| `state` | `"idle" \| "loading" \| "success" \| "error"` | – | Controls the state yourself. Leave it out and onClick's promise drives it. |
| `loadingText` | `ReactNode` | – | Shown next to the spinner. The label stays if left out. |
| `successText` | `ReactNode` | – | Shown next to the check. |
| `errorText` | `ReactNode` | – | Shown next to the cross, on solid red. |
| `resetAfter` | `number \| false` | `2000` | Milliseconds after success or error until it's idle again, or false to stay. Ignored when controlled. |
| `variant` | `Button variant` | `"default"` | The button style. |
| `size` | `Button size` | `"md"` | The button size. |

**When to use it:** Use LoadingButton for actions that take time, like saving or submitting, so people see progress and the result. Return a promise from onClick, or set state yourself. Use Button for instant actions.
**Built on:** Button.
**Accessibility pattern:** https://www.w3.org/WAI/ARIA/apg/patterns/button/
**npm packages (installed by the CLI):** motion@^14.0.0.
**Installs these FewTone UI components with it:** `button`.

## Examples

### Async action

Return a promise from onClick: the button shows loading until it settles, then success or error, then its label again.

```tsx
"use client";

import { LoadingButton } from "@/components/ui/loading-button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";

const delays = [
  { ms: "800", label: "Quick · 0.8s" },
  { ms: "1500", label: "Medium · 1.5s" },
  { ms: "3000", label: "Slow · 3s" },
];

/** Pretend work that settles after `ms`, one way or the other */
const work = (ms: number, succeed: boolean) =>
  new Promise<void>((resolve, reject) =>
    setTimeout(() => (succeed ? resolve() : reject(new Error("Failed"))), ms),
  );

export default function LoadingButtonAsync() {
  const id = useId();
  const [delay, setDelay] = useState("1500");

  return (
    <div className="flex w-full flex-col gap-6 self-stretch">
      <div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
        <label htmlFor={`${id}-delay`} className="text-sm text-muted-foreground">
          Work takes
        </label>
        <Select value={delay} onValueChange={setDelay}>
          <SelectTrigger id={`${id}-delay`} size="sm" className="w-36">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {delays.map((option) => (
              <SelectItem key={option.ms} value={option.ms}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>

      <div className="flex flex-1 flex-wrap items-start justify-center gap-x-6 gap-y-4">
        <div className="flex flex-col items-center gap-1.5">
          <LoadingButton
            onClick={() => work(Number(delay), true)}
            loadingText="Saving…"
            successText="Saved"
            aria-describedby={`${id}-saves`}
          >
            Save changes
          </LoadingButton>
          <span id={`${id}-saves`} className="text-xs text-muted-foreground">
            Succeeds
          </span>
        </div>
        <div className="flex flex-col items-center gap-1.5">
          <LoadingButton
            variant="outline"
            onClick={() => work(Number(delay), false)}
            loadingText="Publishing…"
            errorText="Try again"
            aria-describedby={`${id}-fails`}
          >
            Publish
          </LoadingButton>
          <span id={`${id}-fails`} className="text-xs text-muted-foreground">
            Fails
          </span>
        </div>
      </div>

      <div aria-hidden className="-mb-6 h-(--size-control-sm)" />
    </div>
  );
}
```

### Every state

Set state yourself to control it. The width never changes, because the button is sized to its widest state.

```tsx
import { LoadingButton, type LoadingButtonState } from "@/components/ui/loading-button";
import { useId } from "react";

const states: { state: LoadingButtonState; label: string }[] = [
  { state: "idle", label: "Idle" },
  { state: "loading", label: "Loading" },
  { state: "success", label: "Success" },
  { state: "error", label: "Error" },
];

export default function LoadingButtonStates() {
  const id = useId();
  return (
    <ul className="grid w-fit grid-cols-2 gap-x-4 gap-y-5 sm:grid-cols-4">
      {states.map(({ state, label }) => (
        <li key={state} className="flex flex-col items-center gap-1.5">
          <LoadingButton
            state={state}
            loadingText="Saving…"
            successText="Saved"
            errorText="Try again"
            aria-describedby={`${id}-${state}`}
          >
            Save changes
          </LoadingButton>
          <span id={`${id}-${state}`} className="text-xs text-muted-foreground">
            {label}
          </span>
        </li>
      ))}
    </ul>
  );
}
```

## Related

- [Button](https://ui.fewtone.com/components/button.md): An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.
- [Theme Toggle](https://ui.fewtone.com/components/theme-toggle.md): An accessible React dark mode toggle for Next.js that reveals the new theme from the button with a View Transition: a circle, ink, a wave or a page turn.
