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 clockLaunch checklist
Edited yesterday
Sprint retro notes
Edited 3 days ago
Interview loop questions
Edited last week
"use client"
import * as React from "react"
import { UndoButton } from "mischief-ui/undo-button"
const drafts = [
{ id: "launch", title: "Launch checklist", edited: "Edited yesterday" },
{ id: "retro", title: "Sprint retro notes", edited: "Edited 3 days ago" },
{
id: "hiring",
title: "Interview loop questions",
edited: "Edited last week",
},
]
export function UndoButtonDemo() {
const [items, setItems] = React.useState(drafts)
const [leaving, setLeaving] = React.useState<string[]>([])
return (
<div className="bg-background border-border w-full max-w-[30rem] rounded-[1.25rem] border p-4 shadow-sm">
<div className="flex items-baseline justify-between px-2 pb-2">
<h3 className="text-sm font-semibold">Drafts</h3>
<span className="text-muted-foreground text-xs">
Hover the button to hold the clock
</span>
</div>
{items.length ? (
<ul className="divide-border divide-y">
{items.map((item) => {
const deleting = leaving.includes(item.id)
return (
<li
key={item.id}
className="flex items-center gap-3 px-2 py-3"
data-deleting={deleting || undefined}
>
<div className="min-w-0 flex-1">
<p
className={
deleting
? "text-muted-foreground truncate text-sm line-through"
: "truncate text-sm font-medium"
}
>
{item.title}
</p>
<p className="text-muted-foreground text-xs">
{deleting ? "Deleting" : item.edited}
</p>
</div>
<UndoButton
aria-label={deleting ? undefined : `Delete ${item.title}`}
message={`Deleted ${item.title}`}
onAction={() => setLeaving((ids) => [...ids, item.id])}
onUndo={() =>
setLeaving((ids) => ids.filter((id) => id !== item.id))
}
onCommit={() => {
setLeaving((ids) => ids.filter((id) => id !== item.id))
setItems((list) => list.filter((d) => d.id !== item.id))
}}
/>
</li>
)
})}
</ul>
) : (
<div className="flex flex-col items-center gap-3 px-2 py-8 text-center">
<p className="text-muted-foreground text-sm">No drafts left.</p>
<button
type="button"
className="border-border hover:bg-muted focus-visible:ring-ring/50 min-h-9 rounded-[var(--radius)] border px-3 text-sm outline-none focus-visible:ring-4"
onClick={() => setItems(drafts)}
>
Bring them back
</button>
</div>
)}
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/undo-buttonimport { 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.
"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?: () => voidUsage
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} />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.