# Stateful Button (Moonarc)

One submit button with four faces (idle, pending with a spinner, done with a check that draws itself, error) and a width that follows the label. The state is data-state on the button, set by the prop, by your script, or inherited from a form that carries data-pending. The faces are clipped to a bound derived from the label, so nothing is measured, and only the face that shows is in the accessibility tree. Zero JavaScript of its own.

- Import: `import StatefulButton from '@moonarc/core/StatefulButton'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/stateful-button.json`
- Tier A · category ui · trigger click
- Readout: `<StatefulButton state="pending">`
- Browser support: widely (every browser)
- Measured cost: 0 B JS (CSS 10.9 kB raw)
- Page: https://moonarc.dev/components/stateful-button/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `state` | `'idle' | 'pending' | 'done' | 'error'` | `'idle'` | Current face. The pending state renders the button disabled; a data-state your script writes later changes the face only, so set disabled with it. |
| `labels` | `Partial<Record<State, string>>` | `Submit / Sending / Done / Try again` | Face labels. The default slot replaces the idle label. |
| `type` | `'button' | 'submit' | 'reset'` | `'submit'` | Button type. |

## Usage

```astro
<form data-signup>
  <input type="email" name="email" required />
  <StatefulButton labels={{ idle: 'Subscribe', pending: 'Sending', done: 'You are in', error: 'Try again' }} />
</form>

<script>
  const form = document.querySelector('[data-signup]');
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    // data-pending cannot disable the button: Enter or Space would send again, so a second submit is ignored here
    if (form.hasAttribute('data-pending')) return;
    form.dataset.pending = '';
    const button = form.querySelector('.ma-stateful');
    try {
      const res = await fetch('/api/subscribe', { method: 'POST', body: new FormData(form) });
      button.dataset.state = res.ok ? 'done' : 'error';
    } catch {
      button.dataset.state = 'error';
    } finally {
      delete form.dataset.pending;
    }
  });
</script>
```

## Reduced motion

Faces switch at once; the spinner slows and the check is simply drawn.

## With ClientRouter

CSS-only; the state attribute is on the element and goes with the page.

## Craft

- Width follows the label without measuring: each face opens to a max-width derived from its characters plus the icon, a bound just above the label, so the transition ends where the text ends. This is the RotatingText fluid technique. interpolate-size would not have helped: auto → auto never transitions.
- A form that says data-pending wins over the button's own state (NewsletterSection's contract), and the mouse cannot press it meanwhile. Enter in a field and Space on the button still submit, because CSS cannot disable a button, so the handler that sets data-pending returns early while it is there, as the usage shows.
- The spinner is the library's Spinner and the check is AnimatedIcons, so the three motions share one preset.
- Inactive faces are visibility: hidden, which takes them out of the accessibility tree, so the button is named by the face that shows and aria-live="polite" on it has a new face to announce at each change. visibility transitions with the fade on ease-out: an arriving face counts at once, a leaving one until it has faded.
- The width bound counts 0.62 em per Latin character and a full em for a wide one (CJK, Hangul, kana, full-width forms, emoji), so a Japanese or Korean label opens to its whole length.

## Replaces

- StatefulButton (Aceternity)
- LoadingButton (shadcn examples)

## Source

```astro
---
/**
 * StatefulButton — one button, four faces: idle, pending (a spinner),
 * done (a check that draws itself), error. Zero JS of its own: the state is
 * `data-state` on the button — set by the `state` prop, by your own script,
 * or inherited from a form that carries `data-pending` (the NewsletterSection
 * contract). The faces sit in one row; the inactive ones are clipped to zero
 * width and the active one opens to a bound derived from its label length
 * (the RotatingText `fluid` technique), so the button's width follows the
 * label on the preset's spring with nothing measured. Inactive faces are
 * visibility: hidden, so the button is named by the face that shows and
 * `aria-live="polite"` has a change to announce when the next one appears.
 * The `pending` state disables the button; a form's `data-pending` cannot
 * (no script here), so the handler that sets it ignores submits meanwhile.
 */
import type { HTMLAttributes } from 'astro/types';
import Spinner from './Spinner.astro';
import AnimatedIcons from './AnimatedIcons.astro';

type State = 'idle' | 'pending' | 'done' | 'error';

interface Props extends HTMLAttributes<'button'> {
  /** Current face. */
  state?: State;
  /** Face labels. The default slot, when given, replaces the idle label. */
  labels?: Partial<Record<State, string>>;
  type?: 'button' | 'submit' | 'reset';
}

const { state = 'idle', labels = {}, type = 'submit', class: className, style, ...rest } = Astro.props;
const text: Record<State, string> = { idle: labels.idle ?? 'Submit', pending: labels.pending ?? 'Sending', done: labels.done ?? 'Done', error: labels.error ?? 'Try again' };
const hasSlot = Astro.slots.has('default');
// the open width bound per face: 0.62em per character + the icon and its gap — above any Latin label, never far above it.
// A wide character (CJK, Hangul, kana, full-width forms, emoji) is a full em: at 0.62em "送信中" was cut to its first two.
const WIDE = /[\u1100-\u115f\u2e80-\u303e\u3041-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6]|\p{Extended_Pictographic}|[\u{20000}-\u{3fffd}]/u;
const bound = (label: string, icon: boolean) => `calc(${[...label].reduce((w, c) => w + (WIDE.test(c) ? 1 : 0.62), 0).toFixed(2)}em + ${icon ? '1.6em' : '0em'})`;
---

<button class:list={['ma-stateful', className]} type={type} data-state={state} disabled={state === 'pending' ? true : undefined} aria-live="polite" style={style} {...rest}>
  <span class="ma-stateful__face" data-face="idle" style={`--ma-stateful-w:${hasSlot ? 'none' : bound(text.idle, false)}`}>{hasSlot ? <slot /> : text.idle}</span>
  <span class="ma-stateful__face" data-face="pending" style={`--ma-stateful-w:${bound(text.pending, true)}`}><Spinner size={16} width={2} label="" /><span>{text.pending}</span></span>
  <span class="ma-stateful__face" data-face="done" style={`--ma-stateful-w:${bound(text.done, true)}`}><AnimatedIcons icon="check" state="on" /><span>{text.done}</span></span>
  <span class="ma-stateful__face" data-face="error" style={`--ma-stateful-w:${bound(text.error, true)}`}><AnimatedIcons icon="plus" state="on" /><span>{text.error}</span></span>
</button>

<style is:global>
  @layer components {
    :where(.ma-stateful) {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      padding: 0.65em 1.2em;
      border: 1px solid var(--ma-edge);
      border-radius: 0.6em;
      background: var(--ma-ink);
      color: var(--ma-panel);
      font: inherit;
      font-weight: 500;
      line-height: 1.2;
      white-space: nowrap;
      cursor: pointer;
      transition:
        background-color var(--ma-duration-fast) var(--ma-ease-out),
        opacity var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-stateful:disabled) {
      cursor: progress;
    }
    :where(.ma-stateful:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: 3px;
    }
    /* inactive faces leave the accessibility tree (the CopyButton rule), so the button is named by the one that shows and
       the live region sees a face arrive. visibility transitions with the fade on ease-out: an arriving face counts at
       once, a leaving one until it has faded; an overshooting preset curve would flip it mid-fade */
    :where(.ma-stateful__face) {
      display: inline-flex;
      align-items: center;
      gap: 0.5em;
      max-width: 0;
      opacity: 0;
      visibility: hidden;
      overflow: clip;
      translate: 0 var(--ma-travel-hover);
      transition:
        max-width var(--ma-duration) var(--ma-ease),
        opacity var(--ma-duration) var(--ma-ease),
        translate var(--ma-duration) var(--ma-ease),
        visibility var(--ma-duration) var(--ma-ease-out);
    }
    :where(.ma-stateful__face > *) {
      flex: none;
    }
    :where(.ma-stateful[data-state='idle'] [data-face='idle']),
    :where(.ma-stateful[data-state='pending'] [data-face='pending']),
    :where(.ma-stateful[data-state='done'] [data-face='done']),
    :where(.ma-stateful[data-state='error'] [data-face='error']) {
      max-width: var(--ma-stateful-w, none);
      opacity: 1;
      visibility: visible;
      translate: 0 0;
    }
    /* a form that says it is busy: the button follows, whatever its own state says. pointer-events stops the mouse only;
       Enter and Space still submit, so the handler that sets data-pending ignores submits while it is there */
    :where(form[data-pending] .ma-stateful [data-face]) {
      max-width: 0;
      opacity: 0;
      visibility: hidden;
    }
    :where(form[data-pending] .ma-stateful [data-face='pending']) {
      max-width: var(--ma-stateful-w, none);
      opacity: 1;
      visibility: visible;
      translate: 0 0;
    }
    :where(form[data-pending] .ma-stateful) {
      pointer-events: none;
    }
    :where(.ma-stateful[data-state='done']) {
      background: color-mix(in srgb, var(--ma-ink) 88%, #7cc67c);
    }
    :where(.ma-stateful[data-state='error']) {
      background: color-mix(in srgb, var(--ma-ink) 84%, #e0563f);
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stateful__face) {
        transition: none;
        translate: 0 0;
      }
    }
  }
</style>

```
