# Async Button (Lumen Halo): prompt.md (v1.0.0)

- id: `lumen-async-button` · version 1.0.0 · component · free
- category: Actions
- build: Base UI (one set of files; its dependencies follow the build)
- install (this build): `npx shadcn@latest add https://beautiful-ui.dev/r/lumen-async-button.json`
- npm dependencies: none
- registry dependencies: utils, @beautiful-ui/lumen-button
- docs: https://beautiful-ui.dev/components/lumen-async-button
- The install command carries everything this item needs (files, CSS, tokens, npm and registry dependencies). Prefer it to copying source by hand.

Give it the work and it shows the rest: onClick's promise drives the button through working, done and failed, then it's ready again. No loading flags in your component, and a second press while it runs does nothing.

## Build it
- Stack: React 19 (`ref` is a plain prop), TypeScript, Tailwind CSS v4 utilities, a shadcn-initialised project with the `@/*` alias.
- Packages: none beyond React.
- Files: `components/ui/lumen/controls/async-button.tsx`.
- Registry dependencies, installed with it automatically: shadcn `utils` (cn), `lumen-button`.
- Builds: one set of files for both, but its dependencies come in Base UI and Radix builds. Install the one that matches the project (see Install): a free item's bare URL installs the Base UI build of it and its dependencies.
- Exports to keep: `AsyncButton`, and every exported type.
- CSS: the install adds the lumen foundation (tokens, keyframes, motion levels) to your global stylesheet once. Nothing to import by hand.
- Re-running `add` (or `--overwrite`) re-applies those rules: put overrides in your own CSS, never in the installed rules.
- Tokens: retheme with the `--lumen-*` custom properties (`--lumen-accent`, `--lumen-accent-foreground`, `--lumen-accent-text`, `--lumen-bad`, `--lumen-bad-text`, `--lumen-chart-accent`, `--lumen-chart-accent-foreground`, `--lumen-chart-accent-text`, `--lumen-focus`, `--lumen-good`, `--lumen-good-text`, `--lumen-hairline`, `--lumen-ink`, `--lumen-muted-ink`, `--lumen-series-1`, `--lumen-series-2`, `--lumen-series-3`, `--lumen-series-4`, `--lumen-series-5`, `--lumen-series-6`, `--lumen-warn`, `--lumen-warn-text`). Never add Tailwind colour classes inside the component.

```tsx
import { AsyncButton } from "@/components/ui/lumen/controls/async-button";
```

## Parts

| Part | data-slot | What it is for |
|---|---|---|
| `AsyncButton` | — | The button. |

## Sound
- Keep every `data-slot` and `data-sound` attribute: the sound layer reads them.
- Installing this item adds no audio. Nothing plays until the app mounts `GlassSoundProvider` once (install: `npx shadcn@latest add https://beautiful-ui.dev/r/glass-sound.json`, import from `@/components/beautiful-ui/glass-sound`); `GlassSoundToggle` is its mute control. Without a provider the audio engine never loads.

## Match the original
- Read `components/ui/lumen/controls/async-button.tsx` as the reference implementation before changing or recreating anything, and match it: sizes, colours per theme, motion timings, copy and behaviour.
- If you deviate (a prop you can't honour, a style you changed, a dependency you swapped), say so in your reply, part by part.
- Keep the accessibility contract, the keyboard map and the motion levels listed below.

## Use it when
- async button, loading button, save button, submit with feedback, promise button, Lumen
- Save, publish, send, sync: one piece of work with a clear result

### Not when
- Form submits handled by your form library's own pending state: pass state to Button instead

## Mistakes
- Return the promise from onClick (an arrow with braces and no return loses it)

## Usage

```tsx
"use client";

import { AsyncButton } from "@/components/ui/lumen/controls/async-button";

export function SaveSettings({ settings }: { settings: Record<string, unknown> }) {
  return (
    <AsyncButton
      onClick={() => fetch("/api/settings", { method: "POST", body: JSON.stringify(settings) })}
      doneLabel="Saved"
      errorLabel="Couldn't save"
    >
      Save settings
    </AsyncButton>
  );
}
```

## Props

| Prop | Type | Default | What it does |
|---|---|---|---|
| `motion` | `"full" \| "subtle" \| "off"` | `"full"` | As on Button: subtle is shorter on a calm curve; off is instant. Reduced motion is respected automatically. |
| `onClick` | `(event) => unknown` |  | The work. Return a promise; a rejection or throw shows the error state. |
| `children` | `ReactNode` |  | The label. |
| `doneLabel / errorLabel` | `ReactNode` | `the label / "Try again"` | After success or failure. |
| `resetMs` | `number` | `2000` | How long the result shows. |
| `onError` | `(error) => void` |  | Receives the failure, for logging or a toast. |
| `variant / size / icon` | `Button props` |  | As on Button. |

Full docs: https://beautiful-ui.dev/components/lumen-async-button

## Customising
- Motion: `motion="full"` (default) | `"subtle"` | `"off"`. prefers-reduced-motion is always respected.
- Colours: set the `--lumen-*` tokens on `:root`, or on a container with the `lumen-scope` class to retheme one area. Add the `lumen-inherit` class to follow your shadcn palette instead (`--chart-N`, `--destructive`).
- Dark mode follows the `.dark` class on an ancestor (the shadcn and next-themes convention).
- Update later by re-running the install with `--overwrite` (review the diff if you edited it). Changelog: https://beautiful-ui.dev/r/changelog.json

## Keyboard

| Keys | Action |
|---|---|
| Enter or Space | Run |

## Motion inventory

| Interaction | What moves |
|---|---|
| Run | A ticker while working, then a check draws in (or a ! for errors) |

## Accessibility contract (preserve when editing)
- aria-busy while working; the label keeps its width so nothing jumps
- The result is part of the button's name, so it's read on focus

## Install

Base UI project (a base-* style in components.json):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/lumen-async-button.json
```

Radix project (a radix-*, new-york or default style):

```bash
npx shadcn@latest add https://beautiful-ui.dev/r/radix-nova/lumen-async-button.json
```

Or add the `@beautiful-ui` registry to components.json and run `npx shadcn@latest add @beautiful-ui/lumen-async-button`: the CLI picks the build from your style.

## Credits
- Built on shadcn/ui (https://ui.shadcn.com)

## Source (Base UI build)

### components/ui/lumen/controls/async-button.tsx

```tsx
"use client";
// Generated from src/registry/core/controls/async-button.tsx by scripts/gen-systems.ts. Edit the core file, not this one.

import * as React from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/lumen/button";

/*
 * Async Button: give it the work, it shows the rest. Press it and onClick's promise drives the
 * button: the label holds its width while a ticker runs, then a check draws in (or a ! with your
 * error words), and after a moment it's ready again. No loading flags in your component, and a
 * second press while it runs does nothing.
 *
 *   <AsyncButton onClick={() => saveSettings(form)} doneLabel="Saved">Save settings</AsyncButton>
 */

type Phase = "idle" | "loading" | "success" | "error";

function AsyncButton({
  onClick,
  children,
  doneLabel,
  errorLabel = "Try again",
  resetMs = 2000,
  onError,
  className,
  ...props
}: Omit<React.ComponentProps<typeof Button>, "onClick" | "state" | "children" | "asChild" | "render"> & {
  /** The work. Return a promise; a rejection (or a throw) shows the error state. */
  onClick: (event: React.MouseEvent<HTMLElement>) => unknown;
  children: React.ReactNode;
  /** The label once it succeeds, such as "Saved". Default: the label itself, with a check. */
  doneLabel?: React.ReactNode;
  /** The label after a failure. Default "Try again". */
  errorLabel?: React.ReactNode;
  /** How long success or error shows before the button is ready again. Default 2000ms. */
  resetMs?: number;
  /** Receives the failure, for your logging or toast. */
  onError?: ((error: unknown) => void) | undefined;
}) {
  const [phase, setPhase] = React.useState<Phase>("idle");
  const busy = React.useRef(false);
  const mounted = React.useRef(true);
  const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  React.useEffect(() => {
    mounted.current = true;
    return () => {
      mounted.current = false;
      clearTimeout(timer.current);
    };
  }, []);

  const press = async (e: React.MouseEvent<HTMLElement>) => {
    if (busy.current) return;
    busy.current = true;
    clearTimeout(timer.current);
    setPhase("loading");
    let failed = false;
    try {
      await onClick(e);
    } catch (err) {
      failed = true;
      // Your logging can't break the button: it still shows the failure and resets.
      try {
        onError?.(err);
      } catch (cbErr) {
        console.error(cbErr);
      }
    }
    busy.current = false;
    if (!mounted.current) return;
    setPhase(failed ? "error" : "success");
    timer.current = setTimeout(() => setPhase("idle"), resetMs);
  };

  return (
    <Button data-slot="async-button" state={phase} onClick={press} className={cn(`lumen-async-button`, className)} {...props}>
      {phase === "success" ? (doneLabel ?? children) : phase === "error" ? errorLabel : children}
    </Button>
  );
}

export { AsyncButton };
```
