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.
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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
every browser.
Install
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
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.
npx shadcn@latest add https://moonarc.dev/r/stateful-button.jsonThe 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 — 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
| 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. |
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
- 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). See the migration table.
Related
npx astro add moonarcA 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.
Spinner
LoadingAn indeterminate arc whose length breathes while it rotates, so it looks like it is working rather than spinning a fixed wedge.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown