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";

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).
 * Keyboard holds work via Space/Enter keydown→keyup. 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 (
    <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]",
        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>
    </button>
  );
}