Hold to Confirm Button with Progress Ring

A delete confirmation card with a button that must be pressed and held. While it is held, a ring on the button fills from the top, a darker bar wipes across it and the items that will be lost are crossed out one by one. Hold for the full time and the button turns green, the ring closes around a drawn check and a ring of light bursts outward. Let go early and everything springs back. The preview plays the hold by itself until someone touches it. The card fits a 360px screen.

Code

import { useEffect, useRef, useState } from "react";
import type { KeyboardEvent, PointerEvent } from "react";
import { AnimatePresence, animate, motion, useInView, useMotionValue, useReducedMotion, useTransform } from "framer-motion";
import type { MotionValue } from "framer-motion";

// How long the button must be held before the action fires.
const HOLD_SECONDS = 1.5;
const RING_LENGTH = 2 * Math.PI * 9;

const losses = [
  { amount: "14", label: "projects", detail: "including Harbour Rebrand" },
  { amount: "3", label: "members", detail: "lose access at once" },
  { amount: "4.8 GB", label: "of files", detail: "212 uploads, no backup" },
];

type Phase = "idle" | "holding" | "done";

/** A row that is crossed out as the hold passes its share of the progress. */
function LossRow({ index, progress, amount, label, detail }: { index: number; progress: MotionValue<number>; amount: string; label: string; detail: string }) {
  const from = index / losses.length;
  const to = (index + 1) / losses.length;
  const opacity = useTransform(progress, [from, to], [1, 0.4]);
  const strike = useTransform(progress, [from, to], [0, 1]);
  return (
    <li className="relative flex items-baseline justify-between gap-4 py-3">
      <motion.span style={{ opacity }} className="flex items-baseline gap-2">
        <span className="font-mono text-base font-semibold text-white">{amount}</span>
        <span className="text-sm text-zinc-300">{label}</span>
      </motion.span>
      <motion.span style={{ opacity }} className="truncate text-right text-sm text-zinc-400">
        {detail}
      </motion.span>
      <motion.span aria-hidden="true" style={{ scaleX: strike }} className="pointer-events-none absolute inset-x-0 top-1/2 h-px origin-left bg-rose-400" />
    </li>
  );
}

export default function HoldToConfirmButtonWithProgressRing() {
  const reduceMotion = useReducedMotion();
  const rootRef = useRef<HTMLElement>(null);
  const inView = useInView(rootRef, { amount: 0.3 });
  const [phase, setPhase] = useState<Phase>("idle");
  // The preview holds the button by itself until someone touches it.
  const [demo, setDemo] = useState(true);
  const phaseRef = useRef<Phase>("idle");
  const playback = useRef<{ stop: () => void } | null>(null);

  const progress = useMotionValue(0);
  const ringOffset = useTransform(progress, (value) => RING_LENGTH * (1 - value));
  const fillScale = useTransform(progress, [0, 1], [0, 1]);

  const go = (next: Phase) => {
    phaseRef.current = next;
    setPhase(next);
  };

  const startHold = () => {
    if (phaseRef.current !== "idle") return;
    go("holding");
    playback.current?.stop();
    playback.current = animate(progress, 1, {
      duration: HOLD_SECONDS * (1 - progress.get()),
      ease: "linear",
      onComplete: () => go("done"),
    });
  };

  const cancelHold = () => {
    if (phaseRef.current !== "holding") return;
    go("idle");
    playback.current?.stop();
    if (reduceMotion) progress.set(0);
    else playback.current = animate(progress, 0, { type: "spring", stiffness: 220, damping: 28 });
  };

  const reset = () => {
    playback.current?.stop();
    progress.set(0);
    go("idle");
  };

  // Auto-play: hold, show the result, reset, repeat. Stops for good on the first real interaction.
  useEffect(() => {
    if (!demo || !inView || reduceMotion || phase === "holding") return;
    const timer = setTimeout(phase === "idle" ? startHold : reset, phase === "idle" ? 600 : 2600);
    return () => clearTimeout(timer);
  }, [demo, inView, reduceMotion, phase]);

  // Leaving the screen in the middle of a demo hold lets go of the button.
  useEffect(() => {
    if (demo && !inView) cancelHold();
  }, [demo, inView]);

  useEffect(() => () => playback.current?.stop(), []);

  const takeOver = () => {
    if (!demo) return;
    setDemo(false);
    if (phaseRef.current !== "done") {
      playback.current?.stop();
      progress.set(0);
      phaseRef.current = "idle";
    }
  };

  const onPointerDown = (event: PointerEvent<HTMLButtonElement>) => {
    if (event.button !== 0) return;
    takeOver();
    startHold();
  };
  const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key !== " " && event.key !== "Enter") return;
    event.preventDefault();
    if (event.repeat) return;
    takeOver();
    startHold();
  };
  const onKeyUp = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === " " || event.key === "Enter") cancelHold();
  };

  const done = phase === "done";
  const label = done ? "Workspace deleted" : phase === "holding" ? "Keep holding" : "Hold to delete";

  return (
    <section ref={rootRef} className="relative overflow-hidden bg-zinc-950 px-4 py-14 sm:px-8 sm:py-20">
      <div aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-full w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-full bg-rose-600/30 blur-3xl" />
      <motion.div
        aria-hidden="true"
        className="pointer-events-none absolute left-1/2 top-1/2 h-full w-full max-w-2xl -translate-x-1/2 -translate-y-1/2 rounded-full bg-emerald-500/40 blur-3xl"
        animate={{ opacity: done ? 1 : 0 }}
        transition={{ duration: reduceMotion ? 0 : 0.6 }}
      />

      <motion.div
        initial={reduceMotion ? false : { opacity: 0, y: 24, scale: 0.97 }}
        animate={{ opacity: 1, y: 0, scale: 1 }}
        transition={{ type: "spring", stiffness: 120, damping: 18 }}
        className="relative mx-auto w-full max-w-md rounded-3xl border border-white/10 bg-zinc-900 p-6 shadow-2xl shadow-black/60 sm:p-8"
      >
        <div className="flex items-center gap-3">
          <span className={`flex h-10 w-10 items-center justify-center rounded-xl transition-colors duration-500 ${done ? "bg-emerald-500/15 text-emerald-400" : "bg-rose-500/15 text-rose-400"}`}>
            <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="h-5 w-5">
              <path d="M4 7h16M10 11v6m4-6v6M6 7l1 12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-12M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
          </span>
          <p className={`text-xs font-semibold uppercase tracking-widest transition-colors duration-500 ${done ? "text-emerald-400" : "text-rose-400"}`}>{done ? "Done" : "Danger zone"}</p>
        </div>

        <h2 className="mt-5 text-2xl font-semibold tracking-tight text-white">{done ? "Marlow & Finch Studio is gone" : "Delete Marlow & Finch Studio?"}</h2>
        <p className="mt-2 text-sm leading-6 text-zinc-300">
          {done ? "Everything below was removed a moment ago. You have 30 seconds to change your mind." : "This removes the workspace for everyone. Hold the button all the way to confirm, or let go to back out."}
        </p>

        <ul className="mt-6 divide-y divide-white/10 border-y border-white/10">
          {losses.map((loss, index) => (
            <LossRow key={loss.label} index={index} progress={progress} {...loss} />
          ))}
        </ul>

        <div className="mt-7 flex items-center gap-3">
          <button
            type="button"
            onClick={() => {
              setDemo(false);
              reset();
            }}
            className="inline-flex h-14 shrink-0 items-center rounded-full border border-white/15 px-5 text-sm font-semibold text-white transition-colors hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
          >
            {done ? "Undo" : "Cancel"}
          </button>

          <div className="relative flex-1">
            {/* Shockwave when the hold completes */}
            <AnimatePresence>
              {done && !reduceMotion && (
                <motion.span
                  aria-hidden="true"
                  key="burst"
                  className="pointer-events-none absolute inset-0 rounded-full border-2 border-emerald-400"
                  initial={{ opacity: 0.9, scale: 1 }}
                  animate={{ opacity: 0, scale: 1.25 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: 0.7, ease: "easeOut" }}
                />
              )}
            </AnimatePresence>

            <motion.button
              type="button"
              aria-disabled={done}
              aria-describedby="hold-to-confirm-hint"
              onPointerDown={onPointerDown}
              onPointerUp={cancelHold}
              onPointerLeave={cancelHold}
              onPointerCancel={cancelHold}
              onKeyDown={onKeyDown}
              onKeyUp={onKeyUp}
              onBlur={cancelHold}
              onContextMenu={(event) => event.preventDefault()}
              animate={{
                scale: phase === "holding" ? 0.97 : 1,
                backgroundColor: done ? "#059669" : "#e11d48",
              }}
              transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 20 }}
              className="relative isolate flex h-14 w-full touch-none select-none items-center justify-center gap-3 overflow-hidden rounded-full px-5 text-base font-semibold text-white shadow-lg shadow-black/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900"
            >
              {/* The bar that fills the whole button */}
              <motion.span aria-hidden="true" style={{ scaleX: fillScale, opacity: done ? 0 : 1 }} className="absolute inset-0 -z-10 origin-left bg-rose-900" />

              <span className="relative -ml-2 h-10 w-10 shrink-0 rounded-full bg-black/25">
                {/* Ring: turned a quarter so it fills from twelve o'clock */}
                <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="absolute inset-1 h-8 w-8 -rotate-90">
                  <circle cx="12" cy="12" r="9" stroke="currentColor" strokeOpacity="0.3" strokeWidth="2.5" />
                  <motion.circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeDasharray={RING_LENGTH} style={{ strokeDashoffset: ringOffset }} />
                </svg>
                {done && (
                  <svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="absolute inset-1 h-8 w-8">
                    <motion.path
                      d="m8.2 12.3 2.6 2.6 5-5.4"
                      stroke="currentColor"
                      strokeWidth="2.5"
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      initial={reduceMotion ? false : { pathLength: 0 }}
                      animate={{ pathLength: 1 }}
                      transition={{ duration: 0.35, ease: "easeOut", delay: 0.05 }}
                    />
                  </svg>
                )}
              </span>

              <span className="relative inline-flex h-6 items-center overflow-hidden">
                <AnimatePresence mode="popLayout" initial={false}>
                  <motion.span
                    key={label}
                    className="block whitespace-nowrap"
                    initial={reduceMotion ? false : { y: 16, opacity: 0 }}
                    animate={{ y: 0, opacity: 1 }}
                    exit={reduceMotion ? { opacity: 0 } : { y: -16, opacity: 0 }}
                    transition={{ duration: 0.22, ease: "easeOut" }}
                  >
                    {label}
                  </motion.span>
                </AnimatePresence>
              </span>
            </motion.button>
          </div>
        </div>

        <p id="hold-to-confirm-hint" aria-live="polite" className="mt-4 text-center text-xs text-zinc-400">
          {done ? "Deleted. Press Undo to bring it back." : `Press and hold for ${HOLD_SECONDS} seconds. Space and Enter work too.`}
        </p>
      </motion.div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/hold-to-confirm-button-with-progress-ring.json

When to use it

How to customize it

Accessibility

Questions

How does a hold to confirm button work?

Pressing starts a linear animation of one number from 0 to 1 over the hold time. The ring's `strokeDashoffset`, the fill bar's width and the crossed-out rows all read from that number. If the animation finishes, the action fires. Releasing stops it and animates the number back to 0.

How is the progress ring drawn?

It is an SVG circle with a `strokeDasharray` equal to its circumference. Moving `strokeDashoffset` from the full circumference to zero reveals the stroke. The SVG is rotated a quarter turn so it starts at the top.

Will it fire if the finger slides off the button?

No. Pointer leave, pointer cancel and blur all cancel the hold, and the context menu that a long press opens on phones is suppressed on the button.

Is hold to confirm better than a confirmation dialog?

It is faster for people who mean it and still blocks accidents. For actions with legal or billing weight, a typed confirmation is safer, and the two can be combined.

Why does it press itself in the preview?

So the effect is visible without input. The loop stops for good on the first pointer or key press, and it only runs while the card is on screen.

Related: Magnetic Button That Follows the Pointer, Delete Confirmation Dialog with Summary, Button Set: Primary, Secondary, Ghost, Icon and Loading.

More animated buttons or all free UI components. Start for free to have a full site built around it.

More in Animated Buttons

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap