Actions and inputs
Hold to confirm
A button for risky actions that runs only after a long press, with a ring that fills while you hold and a check that draws when the action is done.
Live preview
Loading preview
A full-width card with "Delete project" and one Hold to confirm button with a trash icon and labels "Hold to delete", "Keep holding", "Deleting", "Deleted" and "Hold to try again"; the demo action waits 900ms and resolves. Replay resets the button to Idle and runs a scripted hold that completes.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add hold-to-confirmUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { HoldToConfirm } from './hold-to-confirm';
const LABELS = {
idle: 'Hold to delete',
holding: 'Keep holding',
pending: 'Deleting',
success: 'Deleted',
error: 'Hold to try again',
};
const HOLD = 1100;
function TrashIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.7 4.2h10.6M6.2 4.2V3c0-.5.4-.9.9-.9h1.8c.5 0 .9.4.9.9v1.2M4 4.2l.6 8.5c.1.7.6 1.2 1.3 1.2h4.2c.7 0 1.2-.5 1.3-1.2l.6-8.5M6.7 7v4M9.3 7v4" />
</svg>
);
}
const pretendDelete = () => new Promise<void>((resolve) => window.setTimeout(resolve, 900));
export default function HoldToConfirmDemo({ replay = 0 }: { replay?: number }) {
const area = React.useRef<HTMLDivElement>(null);
// Replay starts over in Idle and holds Space for the full time.
React.useEffect(() => {
if (replay === 0) return;
const button = area.current?.querySelector('button');
if (!button) return;
const key = (type: string) => button.dispatchEvent(new KeyboardEvent(type, { key: ' ', bubbles: true }));
const timers = [
window.setTimeout(() => key('keydown'), 400),
window.setTimeout(() => key('keyup'), 400 + HOLD + 100),
];
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div
ref={area}
className="flex w-full max-w-[400px] flex-col items-start gap-4 rounded-xl border border-secondary bg-primary p-6 shadow-xs"
>
<div className="flex flex-col gap-1">
<h3 className="text-md font-semibold text-primary">Delete project</h3>
<p className="text-sm text-tertiary">
This removes the project and all of its files. You cannot undo it.
</p>
</div>
<HoldToConfirm
key={replay}
holdDuration={HOLD}
labels={LABELS}
icon={<TrashIcon />}
onConfirm={pretendDelete}
/>
</div>
);
}
API
Parts: root (button), icon slot, leading icon, progress ring (track + fill), spinner, check, error icon, label (text morph), hint (screen reader only), status (live region, screen reader only).
| Prop | Type | Default | Description |
|---|---|---|---|
| holdDuration | number | 1100 | How long to hold, in ms. Clamped to 300-5000. |
| resetAfter | number | 2200 | Time in ms before the Confirmed or Error state goes back to Idle. 0 keeps the result. |
| labels | Partial<Record<"idle" | "holding" | "pending" | "success" | "error", string>> | { idle: "Hold to confirm", holding: "Keep holding", pending: "Confirming", success: "Confirmed", error: "Hold to try again" } | Label for each state. |
| disabled | boolean | false | Turns the button off and cancels a hold in progress. |
| Event | Description |
|---|---|
| confirm | Fires once the hold reaches the full duration. Return a promise to show Confirming until it settles; a rejected promise shows Error. |
| Slot | Description |
|---|---|
| icon | Leading icon shown in Idle and Holding. Default: fingerprint. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| HC1 | hover (Idle) | root | background-color | bg-brand-solid → bg-brand-solid at 90% | duration-xs (160)ease-out | Swaps at once |
| HC2 | press starts (Idle -> Holding) | progress ring | opacity | 0 → 1 | duration-xs (160)ease-exit | Shows at once |
| HC3 | while Holding | progress ring fill | stroke-dashoffset | 1 (empty) → 0 (full) | prop:holdDuration (1100)ease-linear | Runs as is |
| HC4 | release early or Escape (Holding -> Idle) | progress ring fill | stroke-dashoffset | current → 1 (empty) | duration-md (260)ease-out | Hides at once |
| HC5 | release early or Escape (Holding -> Idle) | progress ring | opacity | 1 → 0 | duration-xs (160)ease-exit | Hides at once |
| HC6 | any state change | label | text morph (shared letters slide, new letters rise) | previous label → next label | duration-3xl (460)ease-out | Swaps at once |
| HC7 | hold completes (Holding -> Confirming) | leading icon -> spinner | opacity + filter blur | 1, blur 0 → 0, blur-swap | duration-sm (200)ease-out | Swaps at once |
| HC8 | hold completes (Holding -> Confirming) | leading icon -> spinner | transform scale | 1 → scale-swap | duration-xl (380)ease-out | Swaps at once |
| HC9 | while Confirming | spinner | transform rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| HC10 | action resolves (Confirming -> Confirmed) | spinner -> check | opacity, blur, scale (same as HC7 and HC8) | hidden → shown | duration-xl (380)ease-out | Swaps at once |
| HC11 | action resolves (Confirming -> Confirmed) | check | stroke-dashoffset (draw) | 1 → 0 | duration-xl (380)ease-exitdelay 80ms | Shows at once |
| HC12 | action fails (Confirming -> Error) | spinner -> error icon | opacity, blur, scale (same as HC7 and HC8) | hidden → shown | duration-xl (380)ease-out | Swaps at once |
| HC13 | action fails (Confirming -> Error) | root | background-color, border-color, color | brand solid → error tint (bg-error-primary, border-error, text-error-primary) | duration-xs (160)ease-out | Swaps at once |
| HC14 | resetAfter ends (Confirmed or Error -> Idle) | icons, label, root colors | reverse of HC6, HC10 to HC13 | result → Idle | duration-xl (380)ease-out | Swaps at once |
Accessibility
- Native button; pointer, Space or Enter start the hold, and the action runs only when the hold completes; a plain click does nothing.
- Releasing early, moving the pointer out of the button, blur, Escape, a hidden tab or window blur all cancel the hold.
- aria-describedby points to a hidden hint that says how long to hold and how to cancel, for example "Hold for 1.1 seconds. Release early or press Escape to cancel."
- aria-busy while Confirming; aria-disabled while Confirming and Confirmed so a second hold cannot start.
- A polite live region (role status) reads the current label for every state except Idle.
- Visible focus ring (focus-ring token); the button is 44px tall, above the 24px minimum target.
- touch-action none on the button so a long press does not scroll or open the context menu.
- Reduced motion: icons and labels swap at once and the spinner stops turning; the progress ring still fills, because it shows the hold time.