Confirm button
A two-step button that asks for a second click before it runs an action.
npx shadcn@latest add https://ui.goodtabs.co/r/confirm-button.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/confirm-button
Delete becomes Confirm? for three seconds. Click again to run it.
Custom labels
Mira Okafor mira@goodtabs.co
A named confirm label and a longer timeout in a team member row.
Outline, small
The outline variant at the small size for quieter actions.
Usage
import { ConfirmButton } from "@/components/ui/confirm-button"
<ConfirmButton variant="outline" confirmLabel="Delete it?" onConfirm={remove}>
Delete
</ConfirmButton>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| confirmLabel | string | "Confirm?" | The label shown after the first click. |
| timeout | number | 3000 | How long the confirm label stays before the button reverts, in milliseconds. |
| onConfirm | () => void | Runs on the second click, within the timeout. | |
| variant, size | Button props | "default", "default" | Every shadcn Button prop except onClick passes through. |
Source
confirm-button/confirm-button.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type ConfirmButtonProps = Omit<React.ComponentProps<typeof Button>, "onClick"> & {
/** The label shown after the first click. */
confirmLabel?: string;
/** How long the confirm label stays before the button reverts, in ms. */
timeout?: number;
/** Runs on the second click, within the timeout. */
onConfirm: () => void;
};
/**
* A two-step button. The first click arms it and swaps the label for the confirm label;
* a second click within the timeout runs onConfirm, otherwise it reverts. Escape disarms.
*/
export function ConfirmButton({
confirmLabel = "Confirm?",
timeout = 3000,
onConfirm,
type = "button",
onKeyDown,
className,
children,
...props
}: ConfirmButtonProps) {
const [armed, setArmed] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
React.useEffect(() => () => clearTimeout(timer.current), []);
const disarm = () => {
clearTimeout(timer.current);
setArmed(false);
};
const click = () => {
if (armed) {
disarm();
onConfirm();
return;
}
setArmed(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setArmed(false), timeout);
};
return (
<Button
type={type}
data-armed={armed || undefined}
data-slot="confirm-button"
onClick={click}
onKeyDown={(e) => {
if (e.key === "Escape" && armed) disarm();
onKeyDown?.(e);
}}
className={cn("inline-grid", className)}
{...props}
>
<span
className={cn(
"col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]",
armed && "invisible",
)}
>
{children}
</span>
<span
className={cn(
"col-start-1 row-start-1 inline-flex items-center justify-center gap-[inherit]",
!armed && "invisible",
)}
>
{confirmLabel}
</span>
<span className="sr-only" aria-live="polite">
{armed ? "Press again to confirm" : ""}
</span>
</Button>
);
}
For AI agents
Hand your agent this page, the markdown at /components/confirm-button/prompt.md, or the Copy AI prompt button above.
When to use
- Inline destructive actions in rows and lists where a dialog would be heavy: remove, clear, reset
- Actions that are easy to undo or cheap to redo, but worth a pause
- Dense admin screens where people repeat the action many times
When not to use
- For actions that cannot be undone and affect other people; use a confirm dialog that explains the outcome
- When the button sits where an accidental double click is likely, such as next to a submit
- For actions that need input, such as typing a name to confirm
Agent notes
- It takes a function (onConfirm), so the component rendering it must be a client component
- Both labels sit in the same grid cell, so the button keeps the width of the longer one and nothing shifts
- Armed state is data-armed on the button; an aria-live region announces that a second press confirms
- Escape disarms. For a solid destructive look pass className="bg-destructive text-background", not the soft destructive variant