Hold to confirm
Free · MITPress-and-hold button for destructive actions: a fill sweeps across with the label flipping color at its edge, early release rewinds it, works with Space and Enter.
Installation
$ npx shadcn@latest add https://goodui.dev/r/hold-to-confirm.jsonThe file lands in components/goodui/hold-to-confirm.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/hold-to-confirm works too.
Usage
import { Trash2 } from "lucide-react"
import { HoldToConfirm } from "@/components/goodui/hold-to-confirm"
export function CancelBooking({ onCancel }: { onCancel: () => void }) {
return (
<HoldToConfirm
onConfirm={onCancel}
holdingLabel="Keep holding…"
confirmedLabel="Booking cancelled"
tapHint="Hold to cancel"
>
<Trash2 />
Cancel booking
</HoldToConfirm>
)
}When to use
- Destructive actions that should not happen by accident but do not deserve a dialog: delete a booking, remove a team member, cancel a subscription.
- Kiosk and touch screens (check-in, POS) where a confirm dialog is slow and easy to tap through.
- Confirming irreversible bulk actions inside a row or toolbar, where a modal would break the flow.
- Actions that need explanation or a typed confirmation (deleting a whole workspace): use a dialog such as morphing-dialog.
- Ordinary, reversible actions: use button, and offer undo in a toast-stack instead of slowing everyone down.
- Non-destructive async actions that need loading, success and error states: use stateful-button.
| If you need | Use |
|---|---|
| Delete or cancel | variant="destructive" (default) |
| Non-destructive but deliberate (publish, send to all clients) | variant="default" |
| Neutral surface | variant="outline" |
| Very serious action | duration={2500} |
| Stay in the done state | resetAfter={0} |
Examples
Publish to every client, primary color
<HoldToConfirm variant="default" duration={1200} confirmedLabel="Published" onConfirm={publishSchedule}>
Publish schedule to 48 clients
</HoldToConfirm>API reference
| Prop | Type | Default |
|---|---|---|
| duration Milliseconds the button must be held. | number | 1500 |
| onConfirm Called once when the hold completes. There is no onClick: a tap only shows the hint. | () => void | — |
| variant "destructive" | "default" | "outline": resting look and fill color (red, primary, foreground). | "outline" | "default" | "destructive" | "destructive" |
| size "sm" | "md" | "lg": height, padding and corner radius. | "sm" | "md" | "lg" | "md" |
| holdingLabel Label while holding. | React.ReactNode | — |
| confirmedLabel Label after confirming. | React.ReactNode | — |
| tapHint Shown briefly when the button is only tapped. | React.ReactNode | — |
| resetAfter Milliseconds before returning to idle after confirming; 0 stays confirmed. | number | 2000 |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<HoldToConfirmLabels> | — |
| sound Plays a "tick" when the hold starts and "confirm" when it completes, when the visitor has sounds on (lib/goodui-sounds); a sound name replaces the confirm. Off by default. | boolean | SoundName | — |
Also accepts every <button> attribute.
Accessibility
| Space / Enter (hold) | Starts filling on key down (auto-repeat ignored); releasing early rewinds it. |
| Tab | Moving focus away (blur) cancels a hold in progress. |
- Renders a native <button type="button"> with aria-describedby pointing to a hidden "Press and hold for N seconds to confirm." hint (labels.instructions).
- An assertive live region announces the confirmed label when it is a string, otherwise "Confirmed" (labels.confirmed).
- The destructive variant's text and fill are darkened just enough to keep 4.5:1 contrast in light and dark.
- Works with mouse, touch and keyboard. The context menu and iOS callout are suppressed so a long touch does not open them.
- A quick tap shows tapHint and shakes gently, teaching the gesture instead of failing silently.
Motion
- Holding sweeps a fill across the button with a linear clip-path over `duration` ms (1500); the fill carries its own copy of the label, so the text color flips exactly at the fill edge. Under dir="rtl" the fill sweeps from the right.
- Press scales to 0.97 on a spring; releasing early springs the fill back (stiffness 260, damping 30).
- Confirming pops the button (0.97 → 1.04 → 1 over 0.35 s) and vibrates for 25 ms where supported. After `resetAfter` ms (2000) the fill rewinds over 0.4 s.
- Reduced motion: the fill still tracks the hold (it is the progress indicator), but there is no scale, pop or shake, and rewinds are instant.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Use onConfirm, not onClick: onClick is not accepted, and a normal click only shows the hint.
- duration and resetAfter are milliseconds; resetAfter={0} keeps the confirmed state.
- holdingLabel, confirmedLabel and tapHint are visible texts; built-in text comes from the labels prop (instructions(seconds), confirmed, and the confirmedLabel / tapHint defaults), so pass translations there.
- Variants are destructive (default), default, outline; sizes sm, md, lg.
FAQ
What happens if the user lets go early?
The fill springs back to empty and nothing is called. If they let go within the first 20% it also shows tapHint and shakes.
Is it safe for keyboard users?
Yes. Holding Space or Enter fills it the same way, and moving focus away cancels.
Related
Async button that morphs idle to spinner to drawn check or shaking error, with a spring width change between labels.
One button with ten variants, six tones, seven shadows, ten animations, three shapes and five sizes that all combine, plus loading and icon slots.
A service card expands into a booking dialog, with its image, title and price flying into place through a shared-layout spring, then folds back into the card on Esc or backdrop click.
Self-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.