Components Controls & Input HoldToConfirm

HoldToConfirm

Press-and-hold confirmation for risky actions.

Live demo

01

The three variants

Same gesture, three visual rhythms. Pick the one that fits the surrounding UI density.

ring

SVG stroke-dashoffset sweep around a circular gauge.

bar

Linear horizontal fill expanding behind the label.

glow

Radial pulse blooming from the centre of the button.

0 confirms Β· 0 cancels

Duration sweep

duration is clamped to [200, 10000]ms. Use longer durations for higher-stakes confirms.

Fast β€” accidental holds possible.

Default β€” feels deliberate.

High-stakes β€” actively uncomfortable.

Disabled state

disabled ignores pointer and keyboard input.

Keyboard parity

Tab to the button, then press and hold Enter (or Space) for the full duration. Release before completion cancels.

Implementation

02
HoldToConfirm.svelte
<HoldToConfirm variant="bar" duration={2000} label="Hold to send" onConfirm={sendIrrevocably} />

HoldToConfirm uses pointer capture (setPointerCapture on pointerdown) so the gesture stays alive even if the user drags outside the button β€” release anywhere cancels. Keyboard parity is intentional: Enter and Space start a programmatic hold cycle, repeat events are filtered, and releasing the key before completion cancels exactly like releasing the pointer. Visual progress is pure CSS (stroke-dashoffset for ring, width for bar, opacity+scale for glow); JavaScript only fires the setTimeout that calls onConfirm.

Logic explainer

03

name: HoldToConfirm
category: Inputs & Buttons
author: antclaude
status: shipped

HoldToConfirm

A press-and-hold confirmation button for destructive UX flows: hold-to-delete, hold-to-send, hold-to-leave-call. The user must keep pressure on the control for the full duration β€” release before completion cancels, release after completion fires onConfirm. Designed to defeat habituation: users cannot accidentally tap-confirm an irreversible action.

Pairs naturally with Switch (different idiom: instant boolean toggle vs. gated commit), SwishButton / MagneticButton (different commitment: cosmetic-motion CTAs vs. safety-gated commit), ProgressBar / ProgressRing (different binding: passive value display vs. user-driven gesture timer). Never a substitute for a real progress indicator β€” use a value-bound primitive when you need to communicate progress of a background task.

Key features

  • Three variants β€” ring (SVG stroke-dashoffset fill on a circular gauge), bar (linear horizontal progress filling the button background), glow (radial pulse fill expanding from centre). Each is a distinct visual rhythm, not a colour swap.
  • Pointer + keyboard parity β€” pointerdown / pointerup / pointercancel / pointerleave drive the gesture for mouse / touch / pen. Enter (and Space) start a programmatic hold cycle; releasing the key before duration cancels. Most "hold to confirm" implementations either drop keyboard support or fake it with a 2-step click β€” both defeat the gesture's purpose. This one keeps the contract.
  • Pointer capture β€” setPointerCapture keeps the gesture alive even if the user drags outside the button's bounding box mid-hold.
  • Reduced-motion bypass β€” under prefers-reduced-motion: reduce the gesture collapses to a single-press confirm, with a visible "Press and hold for {duration}s" text fallback explaining the original contract. The dialog text is the fallback safety; the gesture is the primary safety.
  • CSS-driven progress β€” fill animation runs on the GPU (stroke-dashoffset / width / transform: scale); JS only fires the setTimeout for the confirm callback.
  • Pure helpers exported β€” pickVariant, isValidVariant, clampDuration, isReducedMotion. Directly unit-testable without rendering.
  • Confirmed-state hold β€” after onConfirm fires the visible "Confirmed" state holds for 700 ms before resetting, so the user sees their action register.

Usage

<script>
	import HoldToConfirm from '$lib/components/HoldToConfirm.svelte';
</script>

<HoldToConfirm onConfirm={() => deleteAccount()} />

<HoldToConfirm
	variant="bar"
	duration={2000}
	label="Hold to send"
	onConfirm={sendIrrevocably}
/>

<HoldToConfirm
	variant="glow"
	duration={1200}
	label="Hold to leave call"
	onConfirm={hangUp}
/>

<HoldToConfirm disabled label="Saving β€” please wait" />

Props

Prop Type Default Notes
duration number 1500 Clamped [200, 10000] ms. Malformed β†’ 1500.
label string 'Hold to confirm' Visible label and default aria-label.
variant 'ring' | 'bar' | 'glow' 'ring' Unknown β†’ 'ring'.
onConfirm () => void no-op Fires after the user holds for duration ms.
onCancel () => void no-op Fires on release-before-completion or pointercancel.
disabled boolean false Sets aria-disabled and the native disabled attribute.
ariaLabel string | undefined label value Use when the visible label is decorative.
class string '' Extra classes on the wrapper button.

Variant table

Variant Mechanism Vibe
ring SVG stroke-dashoffset 126 β†’ 0 over duration Circular gauge filling around an icon
bar width: 0% β†’ 100% on a fill behind the label Linear progress underneath the text
glow Radial gradient opacity 0 β†’ 1, scale 0.6 β†’ 1 Pulse blooming from the centre

Distinct from

  • Switch β€” instant boolean toggle. No timer, no gesture commitment.
  • SwishButton / MagneticButton β€” single-click CTAs with cosmetic motion. No gating.
  • ProgressBar / ProgressRing β€” passive value display. Bound to data, not user gesture.
  • Stepper / Pagination β€” multi-step navigation. Not a single gated commit.
  • Native confirm() β€” modal interrupt. Users habituate to dismissing it; HoldToConfirm requires sustained intention.

Pure helpers (module-script exports)

  • pickVariant(name) β€” returns 'ring' | 'bar' | 'glow'. Falls back to 'ring'.
  • isValidVariant(name) β€” type guard for variant names.
  • clampDuration(n) β€” clamps to [200, 10000] ms. NaN / Infinity / non-numeric β†’ 1500.
  • isReducedMotion() β€” boolean. Returns false outside the browser.

Accessibility

  • Button carries role="button" (native), aria-label (label or override), aria-pressed (reflects the holding state for AT users), aria-disabled (mirrors the disabled prop).
  • Decorative ring / bar / glow elements are aria-hidden="true" so the label is the only screen-reader text.
  • Keyboard contract is symmetric with pointer: Enter (or Space) starts the hold; releasing the key before duration cancels. Repeat events are filtered so holding the key does not start a second cycle.
  • Visible focus ring (outline: 2px solid var(--htc-color)) β€” the user always knows when the control is focused.
  • Under prefers-reduced-motion: reduce: the gesture collapses to a single-press confirm and a visible text fallback ("Press and hold for {duration}s β€” reduced-motion mode collapses to a single press") explains the original contract. The safety contract is preserved β€” the dialog text replaces the gesture, the user still has to make an explicit decision.

Performance

  • 1 <button> + 1 fill element (SVG / span / span) + 1 label span. No requestAnimationFrame, no canvas, no ResizeObserver.
  • Visible progress is GPU-composited: stroke-dashoffset (ring), width (bar), opacity + transform: scale (glow). The browser interpolates on its own.
  • The JS setTimeout only fires the onConfirm callback; it does not drive the visible animation.

Recipes

  • Hold to delete account: <HoldToConfirm duration={2500} label="Hold to delete account" onConfirm={deleteAccount} />
  • Hold to send irreversible payment: <HoldToConfirm variant="bar" duration={2000} label="Hold to send Β£{amount}" onConfirm={pay} />
  • Hold to leave a call: <HoldToConfirm variant="glow" duration={1200} label="Hold to leave call" onConfirm={hangUp} />
  • Hold to fire the missile: <HoldToConfirm variant="ring" duration={3000} label="Hold to launch" onConfirm={launch} /> β€” the longer the duration, the higher the gesture cost, the lower the false-positive rate.
  • Disabled while saving: <HoldToConfirm disabled label="Saving β€” please wait" />

API

04
PropTypeDefaultDescription
durationnumber1500Hold duration in ms. Clamped to [200, 10000].
variant'ring' | 'bar' | 'glow''ring'Visual progress style.
labelstring'Hold to confirm'Visible button text.
onConfirm() => voidβ€”Fires once the hold completes.
onCancel() => voidβ€”Fires when the gesture is released before completion.
disabledbooleanfalseBlock all input paths.
ariaLabelstringβ€”Override the button's accessible name.