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 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.
Adjust · props are custom properties, set inline, so you edit the running instance
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
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' }} />
<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.
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 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
| Prop | Type | Default | Description |
|---|---|---|---|
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. |
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, or data-state done, exists or error, wins over the button's own state, so a form bound with @moonarc/core/form drives the faces with no code (exists shows the done face). The mouse cannot press it while the form is pending. 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 form lib and the usage do.
- Pending is aria-disabled, not disabled: a disabled button loses the focus the moment it is pressed (Chromium sends it to the body), and a keyboard reader would have to find the form again.
- 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. It is not a live region: a live button would announce its own name each time a face arrives, on top of the form's role="status" that says the same thing in a sentence. visibility transitions with the fade on ease-out: an arriving face counts at once, a leaving one until it has faded.
- The tints and the corner are hooks: --ma-stateful-done and --ma-stateful-error for a coloured ground where the label must keep 4.5 : 1, --ma-stateful-radius over the theme's --ma-radius-md.
- 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