Skip to content
LofiStack Gallery
Type: buttonWeek 02

Hold-to-Confirm Button

Replaces the “Are you sure?” dialog for destructive or important actions. The user presses and holds while a solid fill sweeps across, revealing an inverted copy of the label. Let go early and it springs back; complete the hold and onConfirm runs. Works with mouse, touch, and Space or Enter, and shows a pending state when onConfirm returns a promise.

View on GitHub

Danger zone

Delete “lofi-site”

This removes the project and all deployments. There is no undo.

Press and hold for 1.5 seconds to confirm.
  • Press and hold the button (mouse, touch, Space or Enter).

Usage

usage.tsx
import { HoldToConfirmButton } from "@/components/gallery/hold-to-confirm-button";

<HoldToConfirmButton
  label="Delete project"
  holdingLabel="Keep holding…"
  confirmedLabel="Project deleted"
  icon={<TrashIcon />}
  duration={1500}
  onConfirm={() => api.deleteProject(id)} // a promise shows the pending state
/>

// Softer tone for non-destructive actions
<HoldToConfirmButton label="Publish" tone="primary" onConfirm={publish} />

States

Idle · Hover · Focus-visible ring · Holding (fill progress + press-in) · Cancelled (fill springs back) · Pending (async onConfirm) · Confirmed (check + pop) · Disabled

Press and hold for 1.5 seconds to confirm.
Danger (default)
Press and hold for 1.5 seconds to confirm.
Primary + async
Press and hold for 3 seconds to confirm.
Neutral, 3s hold
Press and hold for 1.5 seconds to confirm.
Small
Press and hold for 1.5 seconds to confirm.
Stays confirmed
Press and hold for 1.5 seconds to confirm.
Disabled

Props

  • label*

    string

    Resting label.

  • onConfirm*

    () => void | Promise<unknown>

    Runs when the hold completes. Return a promise to show a pending spinner; a rejected promise resets the button.

  • holdingLabel= "Keep holding…"

    string

    Shown while the user holds.

  • confirmedLabel= "Done"

    string

    Shown after the hold completes.

  • duration= 1500

    number

    Hold time in milliseconds.

  • tone= "danger"

    "danger" | "primary" | "neutral"

    Color scheme for the base and fill.

  • size= "md"

    "sm" | "md" | "lg"

    Height, padding and font size.

  • icon

    ReactNode

    Icon shown before the resting label.

  • resetAfter= 2000

    number

    Milliseconds before returning to idle after confirming. 0 keeps it confirmed.

  • ref

    Ref<HTMLButtonElement>

    Forwarded to the native button (React 19 ref-as-prop).

  • ...rest

    ButtonHTMLAttributes

    Native button attributes such as disabled, className, aria-* and data-*.

Accessibility

  • A native <button>, so it's focusable and announced as a button.
  • Hold with Space or Enter. Key repeat is ignored and releasing the key cancels.
  • aria-describedby explains “Press and hold for 1.5 seconds to confirm.”
  • A polite live region announces holding, working and confirmed.
  • aria-disabled while pending or confirmed so a second action can't fire.
  • The button keeps its width across labels, and the pop animation is disabled under prefers-reduced-motion.

Build prompt

The final prompt used to build this component
Build a reusable React 19 + TypeScript + Tailwind v4 component called HoldToConfirmButton: a press-and-hold button that replaces "Are you sure?" dialogs for risky actions.

Interaction:
- pointerdown (primary button, with pointer capture) or keydown Space/Enter (ignore key repeat) starts the hold. A setTimeout decides completion after `duration` ms; requestAnimationFrame only paints the progress, so a throttled tab can't stall it.
- Progress drives a fill layer: an absolutely positioned, solid-colored copy of the button content revealed left-to-right with clip-path: inset(0 X% 0 0), so the label inverts as the fill passes it. No transition while holding; a 380ms ease back to 0 when cancelled.
- Releasing early (pointerup, pointercancel, lost capture, keyup, blur) cancels.
- On completion call onConfirm(). If it returns a promise show a pending spinner, and reset to idle if it rejects. Then show a confirmed state (check icon + scale pop + soft ring burst) and reset after `resetAfter` ms (0 = stay confirmed).

Props (typed, extending native button attributes minus children/onClick): label, holdingLabel, confirmedLabel, duration, onConfirm, tone ("danger" | "primary" | "neutral"), size ("sm" | "md" | "lg"), icon, resetAfter, ref (React 19 ref-as-prop).

Details:
- Stack all labels in one CSS grid cell (inactive ones invisible) so the width never jumps.
- Press-in scale while holding, touch-none and select-none, block the long-press context menu.
- Accessibility: native button, aria-describedby hint "Press and hold for N seconds to confirm" and a polite live region placed outside the button so they don't pollute its name, aria-disabled while pending/confirmed, focus-visible ring per tone, AA contrast in light and dark mode.
- Self-contained: no dependencies, keyframes shipped via a React 19 <style href precedence> tag, animations off under prefers-reduced-motion.