"use client";
/**
* HoldToConfirm, from https://motion-brew.com/components/hold-to-confirm
*
* One self contained file: the component and the cn()
* helper. Needs React 18 or 19 and Tailwind CSS v4.
*/
import * as React from "react";
import { type ClassValue, clsx } from "clsx";
import { Trash2 } from "lucide-react";
import { animate, AnimatePresence, type AnimationPlaybackControls, type HTMLMotionProps, motion, useMotionValue, useTransform } from "motion/react";
import { twMerge } from "tailwind-merge";
// cn(): merges Tailwind classes, later ones win.
/** Merge Tailwind classes, letting later classes win conflicts. */
function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs));
}
// HoldToConfirm
/**
* HoldToConfirm
*
* Trigger: press and hold. Holding the button (pointer, or Space / Enter)
* fills it left to right. Let go early and the fill springs back. Hold to the
* end and the label lifts away while a check draws itself and the confirmed
* label rises in letter by letter, at the same button size. `onConfirm` fires,
* then it resets.
* Built for destructive or deliberate actions where a plain click is too easy.
*
* Engine: motion/react (`animate` on a motion value, pathLength for the check,
* AnimatePresence for the label swap). Transform and opacity only.
*/
// Transform and opacity only, so the swap stays on the compositor.
const SWAP = {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -10 },
transition: { type: "spring", stiffness: 500, damping: 32 },
} as const;
const DRAIN = { type: "spring", stiffness: 300, damping: 30 } as const;
const DONE = {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, y: -10 },
transition: { duration: 0.18 },
} as const;
const LETTER = {
hidden: { opacity: 0, y: 8 },
show: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 520, damping: 34 } },
} as const;
/** A check that draws itself, stroke first. */
function DrawnCheck() {
return (
);
}
/** Staggers a string in letter by letter. Other nodes render as they are. */
function Letters({ children }: { children: React.ReactNode }) {
if (typeof children !== "string") return <>{children}>;
return (
{Array.from(children).map((char, i) => (
{char}
))}
);
}
export interface HoldToConfirmProps extends Omit<
HTMLMotionProps<"button">,
"children" | "onPointerDown" | "onPointerUp"
> {
/** Label shown before and during the hold. Default `"Hold to delete"`. */
children?: React.ReactNode;
/** Icon before the label. Default a trash icon. Pass `null` for none. */
icon?: React.ReactNode;
/** Label shown after the hold completes. Default `"Deleted"`. */
confirmedLabel?: React.ReactNode;
/** How long the hold takes, in ms. Default `1200`. */
duration?: number;
/** How long the confirmed state shows before the button resets, in ms. Default `1600`. */
resetAfter?: number;
/** Called once when the hold completes. */
onConfirm?: () => void;
/** Fill color class. Default near-black (near-white in dark mode). */
fillClassName?: string;
}
export const HoldToConfirm = React.forwardRef(function HoldToConfirm(
{
children = "Hold to delete",
icon = ,
confirmedLabel = "Deleted",
duration = 1200,
resetAfter = 1600,
onConfirm,
className,
fillClassName,
disabled,
type = "button",
onKeyDown,
onKeyUp,
onBlur,
...props
},
ref,
) {
const progress = useMotionValue(0);
// White copy of the label is revealed in step with the fill.
const clipPath = useTransform(progress, (p) => `inset(0 ${(1 - p) * 100}% 0 0)`);
const controls = React.useRef(null);
const resetTimer = React.useRef | null>(null);
const [confirmed, setConfirmed] = React.useState(false);
const label = (
<>
{icon}
{children}
>
);
React.useEffect(
() => () => {
controls.current?.stop();
if (resetTimer.current) clearTimeout(resetTimer.current);
},
[],
);
const start = () => {
if (disabled || confirmed) return;
controls.current?.stop();
const remaining = (1 - progress.get()) * duration;
controls.current = animate(progress, 1, {
duration: remaining / 1000,
ease: "linear",
onComplete: () => {
setConfirmed(true);
onConfirm?.();
resetTimer.current = setTimeout(() => {
setConfirmed(false);
controls.current = animate(progress, 0, DRAIN);
}, resetAfter);
},
});
};
const cancel = () => {
if (confirmed || progress.get() === 0) return;
controls.current?.stop();
controls.current = animate(progress, 0, DRAIN);
};
return (
{
e.currentTarget.setPointerCapture(e.pointerId);
start();
}}
onPointerUp={cancel}
onPointerCancel={cancel}
onKeyDown={(e) => {
onKeyDown?.(e);
if ((e.key === " " || e.key === "Enter") && !e.repeat) {
e.preventDefault();
start();
}
}}
onKeyUp={(e) => {
onKeyUp?.(e);
if (e.key === " " || e.key === "Enter") cancel();
}}
onBlur={(e) => {
onBlur?.(e);
cancel();
}}
className={cn(
"relative isolate inline-flex h-11 cursor-pointer select-none items-center justify-center overflow-hidden rounded-full px-6 text-sm font-medium touch-none",
"bg-white text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100",
"outline-none focus-visible:ring-2 focus-visible:ring-zinc-400 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
{/* Sizes the button from the hold label, so both states share one size. */}
{label}
{confirmed ? (
{confirmedLabel}
) : (
{label}
{label}
)}
);
});