Lunia DS

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.

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

Usage

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

PropTypeDefaultDescription
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.
resetAfternumber1800Time in ms after success before the reset event fires.
disabledbooleanfalseTurns the button off.
EventDescription
clickThe person pressed the button. Not fired while loading.
resetFired resetAfter ms after success, for example to go back to idle.
SlotDescription
iconOptional 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
SB1Pressrootscale1 → scale-pressduration-lg (320)ease-outStays still
SB2Idle to any other statusicon slotwidth, gap0, 0 → 20px, 6pxduration-xl (380)ease-outResizes at once
SB3Any status changeincoming iconopacity, blur0, blur-swap → 1, 0duration-md (260)ease-outSwaps at once
SB4Any status changeincoming iconscalescale-swap → 1duration-xl (380)ease-outSwaps at once
SB5Any status changeoutgoing iconopacity, blur1, 0 → 0, blur-swapduration-md (260)ease-outSwaps at once
SB11Any status changeoutgoing iconscale1 → scale-swapduration-xl (380)ease-outSwaps at once
SB6While loadingspinnerrotation0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
SB7Loading to successcheck markstroke drawn0% → 100%duration-2xl (420)ease-outdelay 120msShows at once
SB8Loading to errorroottranslate x0 → -4px, 4px, -2px, 0duration-xl (380)ease-exitStays still
SB9Any status changelabeltext morphSave → Saving, Savedduration-3xl (460)ease-outSwaps at once
SB10Any status changerootbackground color, text colorbrand → error tintduration-lg (320)ease-outSwaps 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.