Skip to content

Action Button

One button that handles the permission check, an optional confirmation, the pending state and a result message for an action. It does not fetch data, show toasts, cache or route: you pass the function that does the work.

Actionv0.1.0experimentalWCAG 2.2 AAView spec
import { ActionButton } from "@rdloom/react";

export default function ActionButtonBasicExample() {
  return (
    <ActionButton
      onAction={async () => {
        // Call your API here.
      }}
      successMessage="Invoice sent"
    >
      Send invoice
    </ActionButton>
  );
}

Installation

npx rdloom add action-button

Copies the source into src/components/rdloom. Edit it freely: rdloom upgrade merges later versions into your changes. It needs react-aria-components; add --install to install them.

Use another registry client
npx shadcn@latest add https://rdloom.vimalbhatt.com/r/action-button.json

Works, but without upgrade tracking.

Usage

import { ActionButton } from "@rdloom/react";

<ActionButton
  onAction={async () => {
    // Call your API here.
  }}
  successMessage="Invoice sent"
>
  Send invoice
</ActionButton>

With confirm

import { ActionButton } from "@rdloom/react";

export default function ActionButtonWithConfirmExample() {
  return (
    <ActionButton
      variant="danger"
      confirm={{
        title: "Delete this user?",
        description: "Asha Menon will lose access immediately. This cannot be undone.",
        confirmLabel: "Delete user",
      }}
      onAction={async () => {
        // Remove the user here.
      }}
      successMessage="User deleted"
      errorMessage="Could not delete the user"
    >
      Delete user
    </ActionButton>
  );
}

Type to confirm

import { ActionButton } from "@rdloom/react";

export default function ActionButtonTypeToConfirmExample() {
  return (
    <ActionButton
      variant="danger"
      confirm={{
        title: "Delete this project?",
        description: "Every file and member in Northwind will be removed.",
        confirmLabel: "Delete project",
        confirmText: "Northwind",
      }}
      onAction={async () => {
        // Remove the project here.
      }}
    >
      Delete project
    </ActionButton>
  );
}

Permission states

Only admins can export
import { ActionButton } from "@rdloom/react";

// The server must still check every action: these only change what people see.
export default function ActionButtonPermissionStatesExample() {
  const act = async () => {};
  return (
    <div className="flex flex-wrap items-center gap-3">
      <ActionButton permission="allow" onAction={act}>
        Allowed
      </ActionButton>
      <ActionButton permission={{ state: "disabled", reason: "Only admins can export" }} variant="secondary" onAction={act}>
        Export (disabled with reason)
      </ActionButton>
      <ActionButton permission="hidden" onAction={act}>
        Hidden
      </ActionButton>
    </div>
  );
}

Async result

Nothing yet

import { useState } from "react";
import { ActionButton, type ActionState } from "@rdloom/react";

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export default function ActionButtonAsyncResultExample() {
  const [log, setLog] = useState("Nothing yet");
  return (
    <div className="flex flex-col gap-3">
      <div className="flex flex-wrap gap-3">
        <ActionButton
          onAction={async () => {
            await wait(1000);
            return "Saved";
          }}
          successMessage="Saved"
          onSuccess={(result) => setLog(`Success: ${String(result)}`)}
          onStateChange={(state: ActionState) => {
            if (state === "pending") setLog("Working");
          }}
        >
          Save changes
        </ActionButton>
        <ActionButton
          variant="secondary"
          onAction={async () => {
            await wait(1000);
            throw new Error("The server said no");
          }}
          errorMessage="Could not sync"
          onError={(error) => setLog(`Error: ${(error as Error).message}`)}
        >
          Sync (fails)
        </ActionButton>
      </div>
      <p className="text-sm text-[var(--rd-color-text-muted)]">{log}</p>
    </div>
  );
}

API Reference

Defined by the spec. Components also accept the props of the React Aria component they wrap.

PropTypeDefault
childrenrequired

Button label. Name the action, such as Delete user.

nodenone
onActionrequired

The work. May be async; its resolved value goes to onSuccess and a throw or rejection goes to onError.

() => void | Promise<unknown>none
permission

What the person may do: allow (default), disabled (shown, focusable, says why) or hidden (renders nothing). Use { state: "disabled", reason } to explain. The server must check again.

PermissionValuenone
confirm

Ask first. Opens an alert dialog (danger tone when the variant is danger); the confirm button runs onAction. confirmText makes the person type a word before it works.

{ title: string; description?: string; confirmLabel?: string; cancelLabel?: string; confirmText?: string }none
variant

Visual emphasis, same as Button.

"primary" | "secondary" | "ghost" | "danger""primary"
size

Height, padding and font size.

"sm" | "md" | "lg""md"
icon

Icon shown before the label. Decorative, so the label must say what the button does.

nodenone
successMessage

Announced politely to screen readers when the action succeeds. Show your own toast in onSuccess if you also want it on screen.

stringnone
errorMessage

Announced politely to screen readers when the action fails.

stringnone
onSuccess

Called with the resolved value when the action succeeds.

(result: unknown) => voidnone
onError

Called with the error when the action throws or rejects.

(error: unknown) => voidnone
onStateChange

Called when the state moves between idle, pending, success and error.

(state: ActionState) => voidnone

Accessibility

Role button, WCAG 2.2 AA. Tested with axe and keyboard tests; screen reader checks are in the audit checklist.

Keyboard

  • Enter activates
  • Space activates
  • With confirm, Enter or Space opens the dialog; Esc cancels it

Screen readers announce

  • Announced as a button with its label
  • Disabled with a reason: announced as dimmed, then the reason is read as its description
  • Pending: announced as unavailable and busy
  • Success or failure: the message is spoken politely after the action settles

What your code must do

  • A disabled action with a reason stays focusable (aria-disabled) and its reason is linked with aria-describedby and a tooltip
  • A disabled action with no reason is a normal disabled button
  • A hidden action renders nothing
  • While pending a second press is ignored and the button is marked busy
  • The result message is announced through a polite status region, not a focus move

Guidelines

Use it when

  • A button that calls your API or mutation and needs permission, confirmation, pending and a result
  • Delete, archive, send and approve actions in tables and forms

Avoid it when

  • Plain navigation: use a link
  • Submitting a Form: use the form's submit button
  • Reading or caching data: that stays with your data layer

Don't

  • Relying on the disabled state as security: the backend must check again
  • Confirming routine, reversible actions
  • Hiding the reason when an action is disabled

Design tokens

The semantic tokens this component uses. Change them once and every component follows; see Design tokens.

  • --rd-color-action-primary
  • --rd-color-action-danger
  • --rd-color-surface-subtle
  • --rd-color-border-default
  • --rd-color-text-default
  • --rd-color-focus-ring
  • --rd-radius-control