Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
confirmLabelstring"Confirm?"The label shown after the first click.
timeoutnumber3000How long the confirm label stays before the button reverts, in milliseconds.
onConfirm() => voidRuns on the second click, within the timeout.
variant, sizeButton 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