caliper uidocs

Action button

Free

A button that runs async work and reports progress, success or failure on itself, at one width.

Installs components/ui/action-button.tsxDepends on @radix-ui/react-slot, lucide-reactMotion none in component source
pnpm dlx shadcn@latest add https://caliperui.site/r/action-button.json

3 commits ready · last deploy 2 hours ago

When to use #

Use it for

  • Save, send and sync actions that take a moment.
  • You want the result shown on the control without a toast.

Skip it when

  • The action is instant; use a button.
  • The result needs a long explanation; show an alert.

Installation #

terminal
pnpm dlx shadcn@latest add https://caliperui.site/r/action-button.json

Adds its 3 files to components/ui/, each with its own stylesheet, and installs their packages. No account needed. The first part also adds Caliper UI’s tokens; import them once as in Installation.

Usage #

This is the code behind the preview above.

example.tsx
import { useState } from "react"
import { ActionButton } from "@/components/ui/action-button"

export function SaveNote() {
  const [fail, setFail] = useState(false);
  const [progress, setProgress] = useState<number | undefined>(undefined);
  const [status, setStatus] = useState("3 commits ready · last deploy 2 hours ago");
  function deploy() {
    setStatus("Uploading 214 files to eu-central-1…");
    return new Promise<void>((resolve, reject) => {
      let value = 0;
      setProgress(0);
      const timer = setInterval(() => {
        value += 8;
        if (fail && value >= 62) {
          clearInterval(timer);
          setProgress(undefined);
          setStatus("Build failed at step 3 of 5: missing STRIPE_KEY. Add it in Settings, then retry.");
          reject(new Error("missing STRIPE_KEY"));
          return;
        }
        if (value >= 100) {
          clearInterval(timer);
          setProgress(undefined);
          setStatus("Live at halden.app · deployed 14:32.");
          resolve();
          return;
        }
        setProgress(value);
      }, 90);
    });
  }
  return (
    <div className="grid justify-items-center gap-3">
      <ActionButton
        label="Deploy to production"
        pendingLabel="Uploading"
        successLabel="Deployed"
        errorLabel="Retry deploy"
        progress={progress}
        onAction={deploy}
      />
      <p role="status" className="max-w-xs text-center text-sm text-muted-foreground">{status}</p>
      <label className="flex items-center gap-2 text-xs text-muted-foreground">
        <input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />
        Make the build fail
      </label>
    </div>
  );
}

API reference #

ActionButton

component
PropTypeDefaultDescription
label *string—
pendingLabelstring"Saving"
successLabelstring"Saved"
errorLabelstring"Try again"Shown after a failure; clicking it runs the action again.
iconReactNode<ArrowRight />Leading icon while idle.
progressnumber—0–100 when the work reports real progress; omit for an honest spinner.
resetAfterMsnumber2400
variant"default" | "secondary" | "ghost" | "destructive""default"
size"sm" | "md" | "lg" | "icon""md"
onAction *() => void | Promise<void>—
onActionError((error: unknown) => void)—

Accessibility #

It is built on native elements and keeps aria-label, aria-busy, aria-hidden and aria-live in step with state. Changes that matter are announced through a live region.

No custom key handling; Tab and the native activation keys do the work.

Motion #

It declares no animation in its component source. Any transition comes from its stylesheet, which uses the duration tokens that collapse to instant under prefers-reduced-motion.