← Back
Micro-interaction

Long Press

Hold-to-confirm button with an SVG circular progress ring driven by requestAnimationFrame.

Hold the button for 1.5s to confirm

requestAnimationFrame · SVG stroke-dasharray · hold-to-confirm UX

LongPress.tsx111 lines
"use client";

import { useRef, useState } from "react";

const DURATION = 1500;

export default function LongPress() {
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<"idle" | "holding" | "done">("idle");
  const startRef = useRef(0);
  const rafRef = useRef(0);

  function begin() {
    if (status === "done") return;
    // eslint-disable-next-line react-hooks/purity
    startRef.current = performance.now();
    setStatus("holding");
    step();
  }

  function step() {
    // eslint-disable-next-line react-hooks/purity
    const pct = Math.min(1, (performance.now() - startRef.current) / DURATION);
    setProgress(pct);
    if (pct < 1) {
      rafRef.current = requestAnimationFrame(step);
    } else {
      setStatus("done");
    }
  }

  function cancel() {
    if (status === "done") return;
    cancelAnimationFrame(rafRef.current);
    setStatus("idle");
    setProgress(0);
  }

  function reset() {
    setStatus("idle");
    setProgress(0);
  }

  const R = 38;
  const C = 2 * Math.PI * R;
  const dash = progress * C;

  return (
    <div className="flex flex-col items-center gap-10 py-6">
      {status === "done" ? (
        <div className="flex flex-col items-center gap-4">
          <div className="w-28 h-28 rounded-full bg-emerald-500 flex items-center justify-center shadow-lg shadow-emerald-500/30">
            <svg viewBox="0 0 24 24" width="44" height="44" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round">
              <polyline points="20 6 9 17 4 12" />
            </svg>
          </div>
          <p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">Action confirmed!</p>
          <button
            onClick={reset}
            className="text-xs text-zinc-400 underline hover:text-zinc-600 transition-colors"
          >
            Reset
          </button>
        </div>
      ) : (
        <div className="relative flex items-center justify-center" style={{ width: 112, height: 112 }}>
          <svg width="112" height="112" viewBox="0 0 112 112" className="-rotate-90 absolute">
            <circle cx="56" cy="56" r={R} fill="none" stroke="currentColor" strokeWidth="4" className="text-zinc-200 dark:text-zinc-800" />
            <circle
              cx="56" cy="56" r={R}
              fill="none"
              stroke={status === "holding" ? "#8b5cf6" : "#d1d5db"}
              strokeWidth="4"
              strokeLinecap="round"
              strokeDasharray={`${dash} ${C}`}
              style={{ transition: status === "holding" ? "none" : "stroke-dasharray 0.3s ease" }}
            />
          </svg>

          <button
            onMouseDown={begin}
            onMouseUp={cancel}
            onMouseLeave={cancel}
            onTouchStart={(e) => { e.preventDefault(); begin(); }}
            onTouchEnd={cancel}
            className="w-20 h-20 rounded-full text-xs font-bold select-none transition-colors duration-200 z-10"
            style={{
              backgroundColor: status === "holding" ? "#8b5cf6" : "#18181b",
              color: "white",
              transform: status === "holding" ? "scale(0.96)" : "scale(1)",
              transition: "transform 100ms ease, background-color 200ms ease",
            }}
          >
            {status === "holding" ? `${Math.round(progress * 100)}%` : "Hold"}
          </button>
        </div>
      )}

      {status !== "done" && (
        <p className="text-xs text-zinc-400 font-mono">
          {status === "idle" ? "Hold the button for 1.5s to confirm" : "Keep holding…"}
        </p>
      )}

      <p className="text-[10px] font-mono text-zinc-400 dark:text-zinc-600">
        requestAnimationFrame · SVG stroke-dasharray · hold-to-confirm UX
      </p>
    </div>
  );
}

More in Micro-interaction