Hold to Confirm

Button with circular progress ring that fills as you hold click to confirm

buttonholdconfirmprogressinteractive
Install dependencies
$npm install framer-motion
Preview

Source Code

"use client";

import { useRef, useState, useCallback } from "react";
import { motion } from "framer-motion";

interface HoldToConfirmProps {
  children: React.ReactNode;
  holdDuration?: number;
  onConfirm?: () => void;
  color?: string;
  className?: string;
}

export default function HoldToConfirm({
  children,
  holdDuration = 1500,
  onConfirm,
  color = "#6366f1",
  className = "",
}: HoldToConfirmProps) {
  const [progress, setProgress] = useState(0);
  const [isHolding, setIsHolding] = useState(false);
  const [confirmed, setConfirmed] = useState(false);
  const startTimeRef = useRef(0);
  const animFrameRef = useRef(0);

  const tick = useCallback(() => {
    const elapsed = Date.now() - startTimeRef.current;
    const pct = Math.min(elapsed / holdDuration, 1);
    setProgress(pct);

    if (pct >= 1) {
      setConfirmed(true);
      setIsHolding(false);
      onConfirm?.();
      setTimeout(() => {
        setConfirmed(false);
        setProgress(0);
      }, 1200);
      return;
    }

    animFrameRef.current = requestAnimationFrame(tick);
  }, [holdDuration, onConfirm]);

  const handleDown = () => {
    if (confirmed) return;
    startTimeRef.current = Date.now();
    setIsHolding(true);
    setProgress(0);
    animFrameRef.current = requestAnimationFrame(tick);
  };

  const handleUp = () => {
    if (!isHolding) return;
    cancelAnimationFrame(animFrameRef.current);
    setIsHolding(false);
    if (progress < 1) {
      setProgress(0);
    }
  };

  const confirmedColor = "#22c55e";
  const activeColor = confirmed ? confirmedColor : color;
  const angle = progress * 360;

  return (
    <div
      className="relative inline-flex rounded-full p-[2px]"
      style={{
        background:
          progress > 0
            ? `conic-gradient(${activeColor} ${angle}deg, transparent ${angle}deg)`
            : "transparent",
      }}
    >
      {/* Idle border (visible when no progress) */}
      {progress === 0 && (
        <div className="pointer-events-none absolute inset-0 rounded-full border border-white/15" />
      )}

      <motion.button
        onMouseDown={handleDown}
        onMouseUp={handleUp}
        onMouseLeave={handleUp}
        onTouchStart={handleDown}
        onTouchEnd={handleUp}
        whileTap={!confirmed ? { scale: 0.97 } : undefined}
        className={`relative inline-flex cursor-pointer items-center justify-center rounded-full bg-gray-900 px-8 py-4 font-semibold text-white select-none transition-colors ${confirmed ? "bg-green-950" : ""} ${className}`}
      >
        <span className="relative z-10">
          {confirmed ? (
            <motion.span
              initial={{ scale: 0 }}
              animate={{ scale: 1 }}
              className="inline-flex items-center gap-2"
            >
              <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#22c55e" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
                <polyline points="20 6 9 17 4 12" />
              </svg>
              Confirmed
            </motion.span>
          ) : (
            children
          )}
        </span>
      </motion.button>
    </div>
  );
}