Actions and inputs
Status button
A button that shows the progress and result of its action in place, so the person never looks away from where they clicked.
Live preview
Loading preview
A Save button. Pressing it goes to loading, then to success after a short wait and back to idle. A second demo button fails and shows Try again with a shake. Replay runs the success flow again from idle.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add status-buttonUsage
The demo above, as code.
demo.tsx
'use client';
import * as React from 'react';
import { StatusButton } from './status-button';
import type { StatusButtonStatus } from './status-button-variants';
// How long the pretend request takes (ms).
const WORK = 1200;
const PUBLISH = { idle: 'Publish', loading: 'Publishing', success: 'Published', error: 'Try again' };
function UploadIcon() {
return (
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none">
<path
d="M10 13.5v-9m0 0-3.5 3.5M10 4.5l3.5 3.5M4 15.5h12"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export default function StatusButtonDemo({ replay = 0 }: { replay?: number }) {
const [save, setSave] = React.useState<StatusButtonStatus>('idle');
const [publish, setPublish] = React.useState<StatusButtonStatus>('idle');
const attempts = React.useRef(0);
const timers = React.useRef<number[]>([]);
const later = React.useCallback((ms: number, run: () => void) => {
timers.current.push(window.setTimeout(run, ms));
}, []);
const runSave = React.useCallback(() => {
setSave('loading');
later(WORK, () => setSave('success'));
}, [later]);
// The first try fails and the retry works, so both results show.
const runPublish = () => {
attempts.current += 1;
const fails = attempts.current % 2 === 1;
setPublish('loading');
later(WORK, () => setPublish(fails ? 'error' : 'success'));
};
React.useEffect(() => {
if (replay === 0) return;
for (const timer of timers.current) window.clearTimeout(timer);
timers.current = [];
setSave('idle');
later(300, runSave);
}, [replay, later, runSave]);
React.useEffect(
() => () => {
for (const timer of timers.current) window.clearTimeout(timer);
},
[],
);
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<StatusButton data-testid="save" status={save} onClick={runSave} onReset={() => setSave('idle')} />
<StatusButton
data-testid="publish"
variant="outline"
status={publish}
labels={PUBLISH}
icon={<UploadIcon />}
onClick={runPublish}
onReset={() => setPublish('idle')}
/>
</div>
);
}
API
Parts: root (button), icon slot, idle icon, spinner, check mark, alert icon, label (text morph), live region (outside the button).
| Prop | Type | Default | Description |
|---|---|---|---|
| status | "idle" | "loading" | "success" | "error" | "idle" | Current step of the action. |
| labels | { idle?: string; loading?: string; success?: string; error?: string } | { idle: "Save", loading: "Saving", success: "Saved", error: "Try again" } | Label for each status. Missing keys use the defaults. |
| variant | "primary" | "outline" | "ghost" | "primary" | Visual weight of the button. |
| size | "sm" | "md" | "md" | sm is 28px tall for toolbars, md is 36px. |
| resetAfter | number | 1800 | Time in ms after success before the reset event fires. |
| disabled | boolean | false | Turns the button off. |
| Event | Description |
|---|---|
| click | The person pressed the button. Not fired while loading. |
| reset | Fired resetAfter ms after success, for example to go back to idle. |
| Slot | Description |
|---|---|
| icon | Optional icon shown before the label while idle. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| SB1 | Press | root | scale | 1 → scale-press | duration-lg (320)ease-out | Stays still |
| SB2 | Idle to any other status | icon slot | width, gap | 0, 0 → 20px, 6px | duration-xl (380)ease-out | Resizes at once |
| SB3 | Any status change | incoming icon | opacity, blur | 0, blur-swap → 1, 0 | duration-md (260)ease-out | Swaps at once |
| SB4 | Any status change | incoming icon | scale | scale-swap → 1 | duration-xl (380)ease-out | Swaps at once |
| SB5 | Any status change | outgoing icon | opacity, blur | 1, 0 → 0, blur-swap | duration-md (260)ease-out | Swaps at once |
| SB11 | Any status change | outgoing icon | scale | 1 → scale-swap | duration-xl (380)ease-out | Swaps at once |
| SB6 | While loading | spinner | rotation | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| SB7 | Loading to success | check mark | stroke drawn | 0% → 100% | duration-2xl (420)ease-outdelay 120ms | Shows at once |
| SB8 | Loading to error | root | translate x | 0 → -4px, 4px, -2px, 0 | duration-xl (380)ease-exit | Stays still |
| SB9 | Any status change | label | text morph | Save → Saving, Saved | duration-3xl (460)ease-out | Swaps at once |
| SB10 | Any status change | root | background color, text color | brand → error tint | duration-lg (320)ease-out | Swaps at once |
Accessibility
- Native button element; Enter and Space activate it.
- While loading the button has aria-busy="true" and aria-disabled="true", and clicks are ignored, so the action cannot run twice.
- A visually hidden role="status" region outside the button announces the new label, so the announcement is not read as part of the button name.
- Icons are aria-hidden; the label always carries the meaning, color is never the only signal.
- Visible focus ring on keyboard focus.