Hold to Confirm
Button with circular progress ring that fills as you hold click to confirm
buttonholdconfirmprogressinteractive
Install dependencies
$npm install framer-motionPreview
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>
);
}