Hold ToConfirm

PreviousNext

A button that requires holding down to confirm irreversible or critical actions.

Docs
scrollxuicomponent

Preview

Loading preview…
components/ui/hold-toconfirm.tsx
"use client";

import * as React from "react";
import {
  motion,
  AnimatePresence,
  useMotionValue,
  animate,
  useTransform,
} from "framer-motion";
import { cn } from "@/lib/utils";
import { buttonVariants, type ButtonProps } from "@/components/ui/button";

interface HoldToConfirmProps extends Omit<ButtonProps, 'asChild'> {
  asChild?: boolean;
  duration?: number;
  onConfirm?: () => void;
  animation?: "border" | "fill";
  fillClassName?: string;
  confirmedChildren?: React.ReactNode;
  confirmedClassName?: string;
  resetAfter?: number;
  showProgressOnConfirm?: boolean;
}

const HoldToConfirm = React.forwardRef<HTMLButtonElement, HoldToConfirmProps>(
  (
    {
      duration = 2000,
      onConfirm,
      animation = "fill",
      variant,
      size,
      className,
      fillClassName,
      confirmedChildren,
      confirmedClassName,
      resetAfter = 2000,
      showProgressOnConfirm = false,
      asChild = false,
      children = "Hold to confirm",
      ...props
    },
    ref
  ) => {
    const [confirmed, setConfirmed] = React.useState(false);
    const textRef = React.useRef<HTMLSpanElement>(null);

    const progress = useMotionValue(0);
    const controlsRef = React.useRef<ReturnType<typeof animate> | null>(null);
    const holdTimerRef = React.useRef<NodeJS.Timeout | null>(null);
    const resetTimerRef = React.useRef<NodeJS.Timeout | null>(null);

    const resetHold = (smooth: boolean) => {
      controlsRef.current?.stop();
      if (smooth) {
        controlsRef.current = animate(progress, 0, {
          duration: 0.3,
          ease: "easeOut",
        });
      } else {
        progress.set(0);
      }
    };

    const startHold = () => {
      if (confirmed) return;

      controlsRef.current = animate(progress, 1, {
        duration: duration / 1000,
        ease: "linear",
      });

      holdTimerRef.current = setTimeout(() => {
        setConfirmed(true);
        onConfirm?.();

        if (!showProgressOnConfirm) {
          resetHold(false);
        } else {
          controlsRef.current?.stop();
          progress.set(1);
        }

        if (resetAfter > 0) {
          resetTimerRef.current = setTimeout(() => {
            setConfirmed(false);
            resetHold(true);
          }, resetAfter);
        }
      }, duration);
    };

    const cancelHold = () => {
      if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
      if (!confirmed) resetHold(true);
    };

    const width = useTransform(progress, [0, 1], ["0%", "100%"]);
    const borderClip = useTransform(
      progress,
      [0, 1],
      [
        "inset(0 100% 0 0 round 0.375rem)",
        "inset(0 0% 0 0 round 0.375rem)",
      ]
    );

    const textProgress = useTransform(progress, (value) => {
      if (!textRef.current) return 0;
      
      const buttonRect = textRef.current.closest('button')?.getBoundingClientRect();
      const textRect = textRef.current.getBoundingClientRect();
      
      if (!buttonRect || !textRect) return 0;
      
      const textStartPercent = (textRect.left - buttonRect.left) / buttonRect.width;
      const fillPosition = value;
      
      if (fillPosition <= textStartPercent) return 0;
      
      const textWidth = textRect.width / buttonRect.width;
      const adjustedProgress = (fillPosition - textStartPercent) / textWidth;
      
      return Math.min(Math.max(adjustedProgress, 0), 1);
    });

    const textWidth = useTransform(textProgress, [0, 1], ["0%", "100%"]);

    React.useEffect(() => {
      return () => {
        controlsRef.current?.stop();
        if (holdTimerRef.current) clearTimeout(holdTimerRef.current);
        if (resetTimerRef.current) clearTimeout(resetTimerRef.current);
      };
    }, []);

    const Comp = asChild ? "span" : "button";

    return (
      <Comp
        ref={ref}
        {...props}
        onMouseDown={startHold}
        onMouseUp={cancelHold}
        onMouseLeave={cancelHold}
        onTouchStart={startHold}
        onTouchEnd={cancelHold}
        className={cn(
          "relative overflow-hidden",
          buttonVariants({ variant, size }),
          className
        )}
      >
        {animation === "fill" && (!confirmed || showProgressOnConfirm) && (
          <motion.div
            className={cn("absolute left-0 top-0 h-full", fillClassName)}
            style={{ width }}
          />
        )}

        {animation === "border" && (!confirmed || showProgressOnConfirm) && (
          <motion.div
            className={cn(
              "absolute inset-0 border-2 rounded-md pointer-events-none",
              fillClassName
            )}
            style={{ clipPath: borderClip }}
          />
        )}

        <span className="relative z-10 flex items-center justify-center w-full">
          <AnimatePresence mode="wait">
            {confirmed && confirmedChildren ? (
              <motion.span
                key="confirmed"
                initial={{ opacity: 0, scale: 0.9, y: 8 }}
                animate={{ opacity: 1, scale: 1, y: 0 }}
                exit={{ opacity: 0, scale: 0.9, y: -8 }}
                transition={{ duration: 0.3 }}
                className={cn("flex items-center gap-2", confirmedClassName)}
              >
                {confirmedChildren}
              </motion.span>
            ) : (
              <motion.span
                key="default"
                initial={{ opacity: 0, scale: 0.9 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={{ opacity: 0, scale: 0.9 }}
                transition={{ duration: 0.25 }}
                className="relative flex items-center gap-2"
              >
                <span ref={textRef} className="relative">
                  {children}
                  {animation === "fill" && (
                    <motion.span
                      className={cn(
                        "absolute inset-0 overflow-hidden",
                        fillClassName?.includes("text-") ? fillClassName : "text-white dark:text-black"
                      )}
                      style={{ width: textWidth }}
                    >
                      {children}
                    </motion.span>
                  )}
                </span>
              </motion.span>
            )}
          </AnimatePresence>
        </span>
      </Comp>
    );
  }
);

HoldToConfirm.displayName = "HoldToConfirm";

export { HoldToConfirm };

Installation

npx shadcn@latest add @scrollxui/hold-toconfirm

Usage

import { HoldToconfirm } from "@/components/hold-toconfirm"
<HoldToconfirm />