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.
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-buttonCopies 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.jsonWorks, 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
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.
| Prop | Type | Default |
|---|---|---|
childrenrequiredButton label. Name the action, such as Delete user. | node | none |
onActionrequiredThe work. May be async; its resolved value goes to onSuccess and a throw or rejection goes to onError. | () => void | Promise<unknown> | none |
permissionWhat 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. | PermissionValue | none |
confirmAsk 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 |
variantVisual emphasis, same as Button. | "primary" | "secondary" | "ghost" | "danger" | "primary" |
sizeHeight, padding and font size. | "sm" | "md" | "lg" | "md" |
iconIcon shown before the label. Decorative, so the label must say what the button does. | node | none |
successMessageAnnounced politely to screen readers when the action succeeds. Show your own toast in onSuccess if you also want it on screen. | string | none |
errorMessageAnnounced politely to screen readers when the action fails. | string | none |
onSuccessCalled with the resolved value when the action succeeds. | (result: unknown) => void | none |
onErrorCalled with the error when the action throws or rejects. | (error: unknown) => void | none |
onStateChangeCalled when the state moves between idle, pending, success and error. | (state: ActionState) => void | none |
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