Skip to content

Hold to Confirm

A fill sweeps while held and cancels on early release.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/hold-to-confirm-button.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/hold-to-confirm-button/hold-to-confirm-button.tsx
    "use client";
    
    import { useEffect, useId, useRef, useState, type ComponentProps, type ReactNode } from "react";
    import {
      AnimatePresence,
      animate,
      motion,
      useMotionValue,
      useReducedMotion,
      useTransform,
      type AnimationPlaybackControls,
    } from "motion/react";
    import { Check } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { duration as durations, ease, spring } from "@/lib/motion";
    
    export interface HoldToConfirmButtonProps
      extends Omit<ComponentProps<"button">, "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Called once the hold completes. */
      onConfirm?: () => void;
      /** Milliseconds the button must be held. @default 1500 */
      duration?: number;
      /** Label shown after confirming. @default "Confirmed" */
      confirmedLabel?: ReactNode;
      /** Milliseconds before resetting after a confirm. 0 keeps it confirmed. @default 2000 */
      resetAfter?: number;
      /** Icon shown before the label. */
      icon?: ReactNode;
      /** Color of the fill. @default "destructive" */
      variant?: "destructive" | "primary" | "neutral";
      /** Button size. @default "md" */
      size?: "md" | "lg";
    }
    
    const fillClasses = {
      destructive: "bg-destructive text-white",
      primary: "bg-primary text-primary-foreground",
      neutral: "bg-foreground text-background",
    } as const;
    
    const sizeClasses = {
      md: "h-10 px-4 text-sm rounded-md",
      lg: "h-12 px-6 text-[15px] rounded-lg",
    } as const;
    
    const HOLD_KEYS = new Set([" ", "Enter"]);
    type Status = "idle" | "holding" | "confirmed";
    
    export function HoldToConfirmButton({
      onConfirm,
      duration = 1500,
      confirmedLabel = "Confirmed",
      resetAfter = 2000,
      icon,
      variant = "destructive",
      size = "md",
      type = "button",
      disabled,
      className,
      children,
      onPointerDown,
      onPointerUp,
      onPointerLeave,
      onPointerCancel,
      onKeyDown,
      onKeyUp,
      onBlur,
      "aria-describedby": describedBy,
      ...props
    }: HoldToConfirmButtonProps) {
      const [status, setStatus] = useState<Status>("idle");
      const progress = useMotionValue(0);
      const clipPath = useTransform(progress, (p) => `inset(0 ${(1 - p) * 100}% 0 0)`);
      const animation = useRef<AnimationPlaybackControls | null>(null);
      const resetTimer = useRef<number | undefined>(undefined);
      const onConfirmRef = useRef(onConfirm);
      const reduceMotion = useReducedMotion();
      const hintId = useId();
    
      useEffect(() => {
        onConfirmRef.current = onConfirm;
      });
    
      useEffect(
        () => () => {
          animation.current?.stop();
          window.clearTimeout(resetTimer.current);
        },
        [],
      );
    
      function start() {
        if (disabled || status === "confirmed") return;
        setStatus("holding");
        animation.current?.stop();
        // Resume from wherever a cancelled hold left the fill.
        animation.current = animate(progress, 1, {
          duration: (duration / 1000) * (1 - progress.get()),
          ease: "linear",
          onComplete: () => {
            setStatus("confirmed");
            onConfirmRef.current?.();
            if (resetAfter > 0) {
              resetTimer.current = window.setTimeout(() => {
                setStatus("idle");
                animation.current = animate(progress, 0, { duration: durations.slow, ease: ease.out });
              }, resetAfter);
            }
          },
        });
      }
    
      function cancel() {
        if (status !== "holding") return;
        animation.current?.stop();
        setStatus("idle");
        animation.current = animate(progress, 0, { duration: durations.base, ease: ease.out });
      }
    
      const swap = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: { opacity: 0, y: 8, filter: "blur(4px)" },
            animate: { opacity: 1, y: 0, filter: "blur(0px)" },
            exit: { opacity: 0, y: -8, filter: "blur(4px)" },
          };
    
      const idleContent = (
        <span className="inline-flex items-center gap-2">
          {icon}
          {children}
        </span>
      );
    
      return (
        <>
          <motion.button
            type={type}
            disabled={disabled}
            aria-describedby={[hintId, describedBy].filter(Boolean).join(" ")}
            animate={{ scale: status === "holding" && !reduceMotion ? 0.97 : 1 }}
            transition={spring.snappy}
            onPointerDown={(event) => {
              onPointerDown?.(event);
              if (event.button === 0) start();
            }}
            onPointerUp={(event) => {
              onPointerUp?.(event);
              cancel();
            }}
            onPointerLeave={(event) => {
              onPointerLeave?.(event);
              cancel();
            }}
            onPointerCancel={(event) => {
              onPointerCancel?.(event);
              cancel();
            }}
            onKeyDown={(event) => {
              onKeyDown?.(event);
              if (!HOLD_KEYS.has(event.key)) return;
              event.preventDefault();
              if (!event.repeat) start();
            }}
            onKeyUp={(event) => {
              onKeyUp?.(event);
              if (HOLD_KEYS.has(event.key)) cancel();
            }}
            onBlur={(event) => {
              onBlur?.(event);
              cancel();
            }}
            onContextMenu={(event) => event.preventDefault()}
            className={cn(
              "relative inline-flex shrink-0 cursor-pointer touch-none items-center justify-center overflow-hidden border border-border bg-background font-medium whitespace-nowrap text-foreground shadow-xs outline-none select-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]:size-4 [&_svg]:shrink-0",
              sizeClasses[size],
              className,
            )}
            {...props}
          >
            {/* Both labels share one cell so the width fits the longer one and never jumps. */}
            <span className="grid *:[grid-area:1/1]">
              {idleContent}
              <span className="invisible inline-flex items-center gap-2" aria-hidden="true">
                <Check />
                {confirmedLabel}
              </span>
            </span>
    
            {/* The fill is a copy of the label in the fill color, revealed with clip-path. */}
            <motion.span
              aria-hidden="true"
              style={{ clipPath }}
              className={cn("absolute inset-0 flex items-center justify-center", fillClasses[variant])}
            >
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={status === "confirmed" ? "confirmed" : "idle"}
                  variants={swap}
                  initial="initial"
                  animate="animate"
                  exit="exit"
                  transition={{ duration: durations.quick, ease: ease.out }}
                  className="inline-flex items-center gap-2"
                >
                  {status === "confirmed" ? (
                    <>
                      <Check strokeWidth={2.5} />
                      {confirmedLabel}
                    </>
                  ) : (
                    <>
                      {icon}
                      {children}
                    </>
                  )}
                </motion.span>
              </AnimatePresence>
            </motion.span>
          </motion.button>
          <span id={hintId} className="sr-only">
            Press and hold for {Math.round(duration / 100) / 10} seconds to confirm.
          </span>
          <span role="status" className="sr-only">
            {status === "confirmed" ? confirmedLabel : null}
          </span>
        </>
      );
    }

Usage

import { Trash2 } from "lucide-react";
import { HoldToConfirmButton } from "@/components/ui/hold-to-confirm-button/hold-to-confirm-button";

export function DeleteProject() {
  return (
    <HoldToConfirmButton icon={<Trash2 />} confirmedLabel="Deleted" onConfirm={() => deleteProject()}>
      Hold to delete
    </HoldToConfirmButton>
  );
}

API reference

PropTypeDefaultDescription
onConfirm() => void–Called once the hold completes.
durationnumber1500Milliseconds the button must be held.
confirmedLabelReactNode"Confirmed"Label shown after confirming.
resetAfternumber2000Milliseconds before resetting after a confirm. 0 keeps it confirmed.
iconReactNode–Icon shown before the label.
variant"destructive" | "primary" | "neutral""destructive"Color of the fill.
size"md" | "lg""md"Button size.

Also accepts every native <button> attribute, including ref and className.

  • The fill is a second copy of the label revealed with clip-path, so the text changes color exactly at the fill edge.
  • A cancelled hold resumes from its current progress if the user presses again before it retracts.

Accessibility

  • Holding Space or Enter works the same as holding the pointer; key repeat is ignored.
  • The button is described by a visually hidden hint, e.g. "Press and hold for 1.5 seconds to confirm."
  • Releasing early, moving the pointer off the button, or moving focus away cancels the hold and retracts the fill.
  • A polite status region announces the confirmed label.
  • With reduced motion enabled, the fill still shows progress (it is the core feedback), but the press scale and label slide are removed.