Confirm Hold Button
Buttons

Confirm Hold Button

A hold-to-confirm button that fills with a clip-path wipe while pressed and snaps back in ~200ms if released early.

Install

npx shadcn@latest add @paragon/confirm-hold-button

confirm-hold-button.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

type HoldState = "idle" | "holding" | "confirmed";

/*
 * Confirmation pop: the hold compresses the button to 0.97, so the keyframe
 * starts there and springs through 1.015 before settling — release physics,
 * not a restart. One-shot and keyed to the confirmed state, so keyframes are
 * fine here; the reduced-motion block flattens the movement away.
 */
const confirmHoldStyles = `
@keyframes pg-chb-confirm {
  0% { scale: 0.97; }
  50% { scale: 1.015; }
  100% { scale: 1; }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-chb-confirm { 0%, 100% { scale: 1; } }
}
`;

export interface ConfirmHoldButtonProps extends React.ComponentProps<"button"> {
  /**
   * How long the button must be held before confirming, in ms.
   * Deliberately allowed past the 300ms UI ceiling — the delay is the feature.
   */
  holdDuration?: number;
  /** How long the filled "confirmed" state lingers before resetting, in ms. */
  confirmedDuration?: number;
  /** Called once, when the hold completes. */
  onConfirm?: () => void;
  /** Color of the confirmation fill. */
  variant?: "default" | "destructive";
  /** Disables press-scale feedback where the motion would distract. */
  static?: boolean;
}

/**
 * A hold-to-confirm button. While pressed, a fill layer wipes across the
 * button via a `clip-path` transition timed to `holdDuration`; releasing
 * early retargets the same transition into a ~200ms snap-back (CSS
 * transitions retarget from the current state — never keyframes here).
 * Completing the hold releases a small scale-settle pop that starts from the
 * held 0.97 compression. Keyboard holds work via Space/Enter keydown→keyup
 * (Escape cancels). Under `prefers-reduced-motion` the wipe is replaced by an
 * opacity fade over the same duration, so progress feedback survives without
 * movement.
 */
export function ConfirmHoldButton({
  holdDuration = 1200,
  confirmedDuration = 800,
  onConfirm,
  variant = "default",
  static: isStatic = false,
  className,
  disabled,
  children = "Hold to confirm",
  onPointerDown,
  onPointerUp,
  onPointerCancel,
  onKeyDown,
  onKeyUp,
  onBlur,
  onContextMenu,
  ...props
}: ConfirmHoldButtonProps) {
  const [state, setState] = React.useState<HoldState>("idle");
  const confirmTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
  const resetTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);

  React.useEffect(() => {
    return () => {
      if (confirmTimer.current) clearTimeout(confirmTimer.current);
      if (resetTimer.current) clearTimeout(resetTimer.current);
    };
  }, []);

  const start = () => {
    if (disabled || state !== "idle") return;
    setState("holding");
    confirmTimer.current = setTimeout(() => {
      confirmTimer.current = null;
      setState("confirmed");
      onConfirm?.();
      resetTimer.current = setTimeout(() => {
        resetTimer.current = null;
        setState("idle");
      }, confirmedDuration);
    }, holdDuration);
  };

  const cancel = () => {
    if (confirmTimer.current) {
      clearTimeout(confirmTimer.current);
      confirmTimer.current = null;
    }
    setState((s) => (s === "holding" ? "idle" : s));
  };

  return (
    <>
      <style href="paragon-confirm-hold-button" precedence="paragon">
        {confirmHoldStyles}
      </style>
      <button
        type="button"
        data-slot="confirm-hold-button"
        data-state={state}
        disabled={disabled}
        onPointerDown={(e) => {
          onPointerDown?.(e);
          if (e.defaultPrevented) return;
          if (e.pointerType === "mouse" && e.button !== 0) return;
          // Capture so releasing outside the button still ends the hold.
          e.currentTarget.setPointerCapture(e.pointerId);
          start();
        }}
        onPointerUp={(e) => {
          onPointerUp?.(e);
          cancel();
        }}
        onPointerCancel={(e) => {
          onPointerCancel?.(e);
          cancel();
        }}
        onKeyDown={(e) => {
          onKeyDown?.(e);
          if (e.key === "Escape") cancel();
          if (e.key === " " || e.key === "Enter") {
            // Suppress the native click/scroll so the hold timer is the only
            // path to confirmation.
            e.preventDefault();
            if (!e.repeat) start();
          }
        }}
        onKeyUp={(e) => {
          onKeyUp?.(e);
          if (e.key === " " || e.key === "Enter") {
            e.preventDefault();
            cancel();
          }
        }}
        onBlur={(e) => {
          onBlur?.(e);
          cancel();
        }}
        onContextMenu={(e) => {
          onContextMenu?.(e);
          // Long-press on touch would otherwise open the context menu mid-hold.
          if (state === "holding") e.preventDefault();
        }}
        className={cn(
          "relative inline-flex h-10 touch-none items-center justify-center gap-2 rounded-lg bg-card px-5 text-sm font-medium whitespace-nowrap select-none shadow-border dark:bg-secondary/30",
          "transition-[scale,box-shadow] duration-150 ease-out hover:shadow-border-hover",
          "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
          "disabled:pointer-events-none disabled:opacity-50",
          "[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
          !isStatic && "data-[state=holding]:scale-[0.97]",
          !isStatic &&
            "data-[state=confirmed]:animate-[pg-chb-confirm_300ms_var(--ease-bounce)]",
          className,
        )}
        {...props}
      >
        <span className="flex items-center gap-2">{children}</span>
        <span
          aria-hidden
          style={{ "--chb-hold": `${holdDuration}ms` } as React.CSSProperties}
          className={cn(
            "pointer-events-none absolute inset-0 flex items-center justify-center gap-2 rounded-[inherit]",
            variant === "destructive"
              ? "bg-destructive text-destructive-foreground"
              : "bg-primary text-primary-foreground",
            // Reduced motion: fade instead of wipe — progress stays legible.
            "transition-[clip-path] motion-reduce:transition-[opacity]",
            state === "idle"
              ? "duration-200 ease-out [clip-path:inset(0_100%_0_0)] motion-reduce:opacity-0"
              : "ease-linear [clip-path:inset(0_0_0_0)] [transition-duration:var(--chb-hold)]",
          )}
        >
          {children}
        </span>
        {/* Completion is otherwise visual-only — announce it for AT users. */}
        <span aria-live="polite" className="sr-only">
          {state === "confirmed" ? "Confirmed" : ""}
        </span>
      </button>
    </>
  );
}