Lunia DS

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.

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-confirm

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
holdDurationnumber1100How long to hold, in ms. Clamped to 300-5000.
resetAfternumber2200Time in ms before the Confirmed or Error state goes back to Idle. 0 keeps the result.
labelsPartial<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.
disabledbooleanfalseTurns the button off and cancels a hold in progress.
EventDescription
confirmFires once the hold reaches the full duration. Return a promise to show Confirming until it settles; a rejected promise shows Error.
SlotDescription
iconLeading 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
HC1hover (Idle)rootbackground-colorbg-brand-solid → bg-brand-solid at 90%duration-xs (160)ease-outSwaps at once
HC2press starts (Idle -> Holding)progress ringopacity0 → 1duration-xs (160)ease-exitShows at once
HC3while Holdingprogress ring fillstroke-dashoffset1 (empty) → 0 (full)prop:holdDuration (1100)ease-linearRuns as is
HC4release early or Escape (Holding -> Idle)progress ring fillstroke-dashoffsetcurrent → 1 (empty)duration-md (260)ease-outHides at once
HC5release early or Escape (Holding -> Idle)progress ringopacity1 → 0duration-xs (160)ease-exitHides at once
HC6any state changelabeltext morph (shared letters slide, new letters rise)previous label → next labelduration-3xl (460)ease-outSwaps at once
HC7hold completes (Holding -> Confirming)leading icon -> spinneropacity + filter blur1, blur 0 → 0, blur-swapduration-sm (200)ease-outSwaps at once
HC8hold completes (Holding -> Confirming)leading icon -> spinnertransform scale1 → scale-swapduration-xl (380)ease-outSwaps at once
HC9while Confirmingspinnertransform rotate0deg → 360deg, repeatsduration-6xl (900)ease-linearNo loop
HC10action resolves (Confirming -> Confirmed)spinner -> checkopacity, blur, scale (same as HC7 and HC8)hidden → shownduration-xl (380)ease-outSwaps at once
HC11action resolves (Confirming -> Confirmed)checkstroke-dashoffset (draw)1 → 0duration-xl (380)ease-exitdelay 80msShows at once
HC12action fails (Confirming -> Error)spinner -> error iconopacity, blur, scale (same as HC7 and HC8)hidden → shownduration-xl (380)ease-outSwaps at once
HC13action fails (Confirming -> Error)rootbackground-color, border-color, colorbrand solid → error tint (bg-error-primary, border-error, text-error-primary)duration-xs (160)ease-outSwaps at once
HC14resetAfter ends (Confirmed or Error -> Idle)icons, label, root colorsreverse of HC6, HC10 to HC13result → Idleduration-xl (380)ease-outSwaps 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.