HoldToConfirm
Press-and-hold confirmation for risky actions.
Live demo
01The 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.
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 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
03name: 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(SVGstroke-dashoffsetfill 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/pointerleavedrive the gesture for mouse / touch / pen.Enter(andSpace) start a programmatic hold cycle; releasing the key beforedurationcancels. 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 β
setPointerCapturekeeps the gesture alive even if the user drags outside the button's bounding box mid-hold. - Reduced-motion bypass β under
prefers-reduced-motion: reducethe 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 thesetTimeoutfor the confirm callback. - Pure helpers exported β
pickVariant,isValidVariant,clampDuration,isReducedMotion. Directly unit-testable without rendering. - Confirmed-state hold β after
onConfirmfires 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. Returnsfalseoutside 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 thedisabledprop). - 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(orSpace) starts the hold; releasing the key beforedurationcancels. 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. NorequestAnimationFrame, no canvas, noResizeObserver. - Visible progress is GPU-composited:
stroke-dashoffset(ring),width(bar),opacity + transform: scale(glow). The browser interpolates on its own. - The JS
setTimeoutonly fires theonConfirmcallback; 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| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 1500 | Hold duration in ms. Clamped to [200, 10000]. |
variant | 'ring' | 'bar' | 'glow' | 'ring' | Visual progress style. |
label | string | 'Hold to confirm' | Visible button text. |
onConfirm | () => void | β | Fires once the hold completes. |
onCancel | () => void | β | Fires when the gesture is released before completion. |
disabled | boolean | false | Block all input paths. |
ariaLabel | string | β | Override the button's accessible name. |