Buttons & Actionsv0.1.0
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.jsonManual
Install the dependencies
npm install clsx lucide-react motion tailwind-mergeAdd the shared helpers
Components use
cn()fromlib/utils.tsand the motion tokens below. See Installation if you have not set them up yet.lib/motion.tsimport 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 } }, }; }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
| Prop | Type | Default | Description |
|---|---|---|---|
onConfirm | () => void | – | Called once the hold completes. |
duration | number | 1500 | Milliseconds the button must be held. |
confirmedLabel | ReactNode | "Confirmed" | Label shown after confirming. |
resetAfter | number | 2000 | Milliseconds before resetting after a confirm. 0 keeps it confirmed. |
icon | ReactNode | – | 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.