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 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.

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 10.9 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' }} />
</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>
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 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>

Props

PropTypeDefaultDescription
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.
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