Mischief

124 / Controls

Undo Button

An action that looks done at once and only commits after a few seconds. Until then the button is Undo.

Drafts

Hover the button to hold the clock
  • Launch checklist

    Edited yesterday

  • Sprint retro notes

    Edited 3 days ago

  • Interview loop questions

    Edited last week

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/undo-button
import { UndoButton } from "mischief-ui/undo-button"

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/undo-button/undo-button.tsx
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type UndoButtonState = "idle" | "pending" export interface UndoButtonProps extends Omit<  React.ButtonHTMLAttributes<HTMLButtonElement>,  "children"> {  /** Runs at once, so the host can show the action as done. */  onAction?: () => void

Usage

export function DraftRow({ draft }: { draft: Draft }) {
  const [deleting, setDeleting] = React.useState(false)

  return (
    <li className={deleting ? "opacity-60" : ""}>
      {draft.title}
      <UndoButton
        message={`Deleted ${draft.title}`}
        onAction={() => setDeleting(true)}
        onUndo={() => setDeleting(false)}
        onCommit={() => deleteDraft(draft.id)}
      />
    </li>
  )
}

Three moments, three callbacks

onAction runs on the click, so the row can look deleted straight away. onUndo runs if the person takes it back. onCommit runs once the window ends without an undo, and that is the only place the real work belongs. A host that only passes onCommit still gets the countdown and the Undo.

If the button leaves the page while the window is open, the action commits. The person was told it happened, so it happens.

Who the clock waits for

The countdown stops while a mouse rests on the button and while keyboard focus is on it, and starts again from where it stopped when either moves away. Someone reading the button with a screen reader or deciding with the pointer over it is never raced. The focus a mouse click leaves behind does not hold the clock, or a mouse user would never see the action commit.

<UndoButton duration={8000} message="Moved to trash" onCommit={trash} />
duration is in milliseconds and has a floor of one second.

API

onCommit() => voidRuns once the window ends without an undo.
onAction() => voidRuns at once on the click, for showing the action as done.
onUndo() => voidRuns when the person presses Undo.
durationnumberThe grace window in milliseconds. Defaults to 5000.
childrenReactNodeThe label before the action. Defaults to Delete.
undoLabelstringThe label during the window. Defaults to Undo.
message, undoneMessagestringWhat is announced when the action is taken and when it is undone. Default Deleted and Undone.
data-state (UndoButtonState)"idle" | "pending"Whether the window is open. data-paused is also set while the clock is held.
classNamestringClasses for the button.
...buttonPropsButtonHTMLAttributesNative button attributes. onClick runs before the button's own handling, and preventDefault stops it.

Accessibility

It stays one native button, so its name changes from the action to Undo and focus never moves. Taking the action is announced politely with the time left, for example: Deleted. Undo available for 5 seconds. Undoing is announced too. The seconds are shown as text and the draining bar is decoration, so nothing depends on seeing the bar. The countdown pauses while the button has keyboard focus or a hovering mouse, which meets the timing adjustable criterion without a separate control. Reduced motion removes the draining bar and keeps the seconds.