Skip to content

Components / UI

Stateful Button

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 its form: data-pending and data-state done, exists or error, the attributes @moonarc/core/form writes. 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.

Live demo

idle → pending → done · one attribute

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 11.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

every browser.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import StatefulButton from '@moonarc/core/StatefulButton';
---
<form data-signup>
  <input type="email" name="email" required />
  <StatefulButton labels={{ idle: 'Subscribe', pending: 'Sending', done: 'You are in', error: 'Try again' }} />
  <p role="status"></p>
</form>

<script>
  const form = document.querySelector('[data-signup]');
  form.addEventListener('submit', async (e) => {
    e.preventDefault();
    // data-pending cannot stop Enter or Space from sending again, so a second submit is ignored here
    if (form.hasAttribute('data-pending')) return;
    form.dataset.pending = '';
    delete form.dataset.state;
    let ok = false;
    try {
      ok = (await fetch('/api/subscribe', { method: 'POST', body: new FormData(form) })).ok;
    } catch {}
    delete form.dataset.pending;
    form.dataset.state = ok ? 'done' : 'error';
    // the button is not a live region: say it in the form's role="status"
    form.querySelector('[role="status"]').textContent = ok ? 'You are in.' : 'That did not go through. Try again.';
  });
</script>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs Spinner, the base tokens and Animated Icons.

terminal
npx shadcn@latest add https://moonarc.dev/r/stateful-button.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win. If you paste it, also copy Spinner.astro and AnimatedIcons.astro.

StatefulButton.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 its form: `data-pending` shows the pending face, and
 * `data-state` done, exists or error the matching one (exists shows done),
 * which is what @moonarc/core/form writes. 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. The
 * button is not a live region: the announcement is the form's role="status"
 * (which the form lib requires) or the caller's. The `pending` state renders
 * aria-disabled, not disabled, so the focus stays on the button; neither it
 * nor a form's `data-pending` can stop a submit (no script here), so the
 * handler that sets them 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} aria-disabled={state === 'pending' ? 'true' : undefined} 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: var(--ma-stateful-radius, var(--ma-radius-md, 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[aria-disabled='true']) {
      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.
       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 where it is: the button follows, whatever its own state says. data-pending shows the pending face,
       data-state done, exists or error the matching one (exists is done: the address was already in), the attributes the
       form lib writes. pointer-events stops the mouse only; Enter and Space still submit, so the handler that sets
       data-pending ignores submits while it is there (the form lib does) */
    :where(form:is([data-pending], [data-state='done'], [data-state='exists'], [data-state='error']) .ma-stateful [data-face]) {
      max-width: 0;
      opacity: 0;
      visibility: hidden;
    }
    :where(form[data-pending] .ma-stateful [data-face='pending']),
    :where(form:is([data-state='done'], [data-state='exists']):not([data-pending]) .ma-stateful [data-face='done']),
    :where(form[data-state='error']:not([data-pending]) .ma-stateful [data-face='error']) {
      max-width: var(--ma-stateful-w, none);
      opacity: 1;
      visibility: visible;
      translate: 0 0;
    }
    :where(form[data-pending] .ma-stateful) {
      pointer-events: none;
    }
    /* the state tints are hooks: on a coloured ground a caller points --ma-stateful-done / --ma-stateful-error at a colour
       its label keeps 4.5 : 1 on (the words carry the state either way) */
    :where(.ma-stateful[data-state='done']) {
      background: var(--ma-stateful-done, color-mix(in srgb, var(--ma-ink) 88%, #7cc67c));
    }
    :where(.ma-stateful[data-state='error']) {
      background: var(--ma-stateful-error, color-mix(in srgb, var(--ma-ink) 84%, #e0563f));
    }
    /* after the button's own: the form's state wins, as it does for the faces */
    :where(form:is([data-state='done'], [data-state='exists']) .ma-stateful) {
      background: var(--ma-stateful-done, color-mix(in srgb, var(--ma-ink) 88%, #7cc67c));
    }
    :where(form[data-state='error'] .ma-stateful) {
      background: var(--ma-stateful-error, color-mix(in srgb, var(--ma-ink) 84%, #e0563f));
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-stateful__face) {
        transition: none;
        translate: 0 0;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
state'idle' | 'pending' | 'done' | 'error''idle'Current face. The pending state renders aria-disabled="true", not disabled, so the focus stays on the button; a data-state your script writes later changes the face only, so write aria-disabled with it. Inside a form, the form's data-pending and data-state win.
labelsPartial<Record<State, string>>Submit / Sending / Done / Try againFace labels. The default slot replaces the idle label.
type'button' | 'submit' | 'reset''submit'Button type.

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.

Why it is built this way

Replaces: StatefulButton (Aceternity) · LoadingButton (shadcn examples). See the migration table.

Six line icons that morph into a second state: menu to close, plus to close, copy to check, play to pause, sun to moon, and a check that draws itself.

Chrome 52 · Firefox 97 · not Safariclick
npx astro add moonarc
click · check · back

A button that copies text to the clipboard: the icon turns into a check, the label says "Copied", and both come back after the ambient duration.

every browserclick

Spinner

Loading

An indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge.

every browseralways

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown