All patterns

Destructive Hold to Confirm

Holding fills the control at an honest rate; letting go early drains it back several times faster.

feedbackpremiumcalminteraction · finite · advanced · ~1.4s
Interactive · click to play
Variant

The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.

340 lines · react + motion only
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion } from "motion/react";

/**
 * Vibary · Destructive Hold to Confirm
 *
 * A delete that asks for a second of deliberate pressure instead of a
 * second dialog. Holding fills the control at a steady, honest rate;
 * letting go early drains it back several times faster, so an accidental
 * press visibly un-happens rather than trailing off. The asymmetry is the
 * whole design: committing is slow, aborting is instant.
 *
 * Self-contained: depends only on `motion` (react ships with your app).
 * Surfaces are mixed from the inherited text color, so the control reads
 * correctly on a light page and on a dark one.
 * Works with zero props; tune via `variant`, `label`, `holdMs`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type DestructiveHoldFillProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Resting label. */
  label?: string;
  /** Label while the control is being held. */
  holdingLabel?: string;
  /** Label once the hold completes. */
  doneLabel?: string;
  /** Line under the control explaining the gesture. */
  hint?: string;
  /** How long the hold has to last. Overrides the variant when set. */
  holdMs?: number;
  /** How long the confirmed state is held before returning to rest, in ms. */
  resetMs?: number;
  /** Width — px number or any CSS length. */
  width?: number | string;
  /** Fires when the hold completes. */
  onConfirm?: () => void;
  /** Fires when a hold is abandoned before it completes. */
  onAbort?: () => void;
};

type VariantConfig = {
  /** Seconds a full hold takes. */
  fillSeconds: number;
  /** How many times faster the drain runs than the fill. */
  drainFactor: number;
  /** Spring the confirmed state arrives on. */
  spring: { type: "spring"; stiffness: number; damping: number };
};

// The confirmed state lands on a spring that settles once — ζ = damping /
// 2√stiffness stays at or above 0.89 — because the word underneath it is
// the answer to "did I just delete something". Variants change how long
// the commitment takes; every one of them drains at least 2.6× faster
// than it fills, since that ratio is what makes an abort feel safe.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // A short, unfussy second. For a control that removes one row.
  subtle: {
    fillSeconds: 1,
    drainFactor: 2.6,
    spring: { type: "spring", stiffness: 520, damping: 46 },
  },
  // The all-purpose setting.
  default: {
    fillSeconds: 1.4,
    drainFactor: 3.2,
    spring: { type: "spring", stiffness: 420, damping: 39 },
  },
  // A longer, more deliberate hold — for something that cannot be undone
  // at all, like emptying a workspace.
  playful: {
    fillSeconds: 1.9,
    drainFactor: 3.6,
    spring: { type: "spring", stiffness: 340, damping: 33 },
  },
};

/** Theme-adaptive neutral: mixing the inherited text color with
 *  `transparent` lands correctly on a light surface and on a dark one.
 *  The two semantic colors stay literal — they carry meaning. */
const tone = (percent: number) =>
  `color-mix(in srgb, currentColor ${percent}%, transparent)`;

const DANGER = "#E5484D";
const DONE = "#10B981";
const RADIUS = 10;

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

export default function DestructiveHoldFill({
  variant = "default",
  label = "Hold to delete workspace",
  holdingLabel = "Keep holding",
  doneLabel = "Workspace deleted",
  hint = "Press and hold. Let go at any point to cancel.",
  holdMs,
  resetMs = 2200,
  width = 300,
  onConfirm,
  onAbort,
}: DestructiveHoldFillProps) {
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const fillSeconds = holdMs !== undefined ? holdMs / 1000 : cfg.fillSeconds;

  const [phase, setPhase] = useState<Phase>("idle");

  // 0 → 1. The bar is a scaleX off this value, so the fill is composited
  // rather than relaid out, and the drain can read the exact point the
  // hold was abandoned at.
  const progress = useMotionValue(0);
  const running = useRef<{ stop: () => void } | null>(null);
  const held = useRef(false);

  const callbacks = useRef({ onConfirm, onAbort });
  useEffect(() => {
    callbacks.current = { onConfirm, onAbort };
  }, [onConfirm, onAbort]);

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

  useEffect(() => {
    if (phase !== "done") return;
    const timer = setTimeout(() => {
      // Reset on the same drain the abort uses, so the control never
      // snaps back to empty in a single frame.
      running.current = animate(progress, 0, { duration: 0.3, ease: "easeOut" });
      setPhase("idle");
    }, resetMs);
    return () => clearTimeout(timer);
  }, [phase, resetMs, progress]);

  const start = () => {
    if (phase !== "idle" || held.current) return;
    held.current = true;
    setPhase("holding");
    running.current?.stop();
    // Linear on the way in: the rate has to be honest, because the only
    // thing the fill promises is how much longer this will take.
    running.current = animate(progress, 1, {
      duration: fillSeconds,
      ease: "linear",
      onComplete: () => {
        if (!held.current) return;
        held.current = false;
        setPhase("done");
        callbacks.current.onConfirm?.();
      },
    });
  };

  const release = () => {
    if (!held.current) return;
    held.current = false;
    running.current?.stop();
    const reached = progress.get();
    setPhase("idle");
    if (reached <= 0) return;
    callbacks.current.onAbort?.();
    // The drain is proportional to how far the hold got, at several times
    // the fill rate, and eased out so it leaves quickest at the start.
    // Anything slower reads as the gesture still deciding.
    running.current = animate(progress, 0, {
      duration: (reached * fillSeconds) / cfg.drainFactor,
      ease: "easeOut",
    });
  };

  const isDone = phase === "done";
  const currentLabel = isDone
    ? doneLabel
    : phase === "holding"
      ? holdingLabel
      : label;

  return (
    <div style={{ width }}>
      <button
        type="button"
        onPointerDown={start}
        onPointerUp={release}
        onPointerLeave={release}
        onPointerCancel={release}
        // The keyboard gets the same contract: hold the key, not tap it.
        onKeyDown={(event) => {
          if (event.repeat) return;
          if (event.key === " " || event.key === "Enter") {
            event.preventDefault();
            start();
          }
        }}
        onKeyUp={(event) => {
          if (event.key === " " || event.key === "Enter") release();
        }}
        onBlur={release}
        disabled={isDone}
        style={{
          position: "relative",
          display: "block",
          width: "100%",
          height: 40,
          padding: 0,
          borderRadius: RADIUS,
          border: `1px solid ${
            isDone
              ? `color-mix(in srgb, ${DONE} 40%, transparent)`
              : `color-mix(in srgb, ${DANGER} 34%, transparent)`
          }`,
          transition: "border-color 220ms ease-out",
          background: tone(4),
          color: "inherit",
          font: "inherit",
          overflow: "hidden",
          cursor: isDone ? "default" : "pointer",
          touchAction: "none",
          WebkitTapHighlightColor: "transparent",
        }}
      >
        {/* The fill. scaleX off the left edge is a transform, so holding
            costs nothing per frame; the same motion value is what the
            drain reads to know how far the gesture got. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: isDone ? 0 : 1 }}
          transition={{ duration: 0.26, ease: "easeOut" }}
          style={{
            position: "absolute",
            inset: 0,
            transformOrigin: "left center",
            scaleX: progress,
            background: `color-mix(in srgb, ${DANGER} 22%, transparent)`,
          }}
        />
        {/* Confirmed ground is a second layer crossfading over the first,
            because a color-mix() value cannot be interpolated by an
            animation — only faded between. */}
        <motion.span
          aria-hidden
          initial={false}
          animate={{ opacity: isDone ? 1 : 0 }}
          transition={{ duration: 0.26, ease: "easeOut" }}
          style={{
            position: "absolute",
            inset: 0,
            background: `color-mix(in srgb, ${DONE} 16%, transparent)`,
          }}
        />

        <span
          style={{
            position: "relative",
            display: "grid",
            placeItems: "center",
            height: "100%",
            fontSize: 13,
            fontWeight: 650,
          }}
        >
          <AnimatePresence initial={false}>
            <motion.span
              key={currentLabel}
              // The word changes by crossfade at one constant size. Text
              // that scales into a confirmation is text you have to read
              // twice, which is the last thing a delete needs.
              initial={{ opacity: 0, y: reduceMotion ? 0 : 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: reduceMotion ? 0 : -6 }}
              transition={{
                opacity: { duration: 0.16, ease: "easeOut" },
                y: cfg.spring,
              }}
              style={{
                gridArea: "1 / 1",
                display: "inline-flex",
                alignItems: "center",
                gap: 7,
                color: isDone ? DONE : DANGER,
                whiteSpace: "nowrap",
              }}
            >
              {isDone ? (
                <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
                  <motion.path
                    d="M3.4 8.4 6.3 11.3 12.6 5"
                    stroke="currentColor"
                    strokeWidth="1.9"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    initial={{ pathLength: reduceMotion ? 1 : 0 }}
                    animate={{ pathLength: 1 }}
                    transition={{ duration: reduceMotion ? 0 : 0.26, ease: "easeOut" }}
                  />
                </svg>
              ) : (
                <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden>
                  <path
                    d="M3 4.5h10M6.4 4.5V3.2h3.2v1.3M4.4 4.5l.6 8.1h6l.6-8.1"
                    stroke="currentColor"
                    strokeWidth="1.4"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
              )}
              {currentLabel}
            </motion.span>
          </AnimatePresence>
        </span>
      </button>

      <div
        style={{
          marginTop: 8,
          fontSize: 11.5,
          opacity: 0.5,
          lineHeight: 1.45,
          textAlign: "center",
        }}
      >
        {hint}
      </div>

      <span
        aria-live="polite"
        style={{
          position: "absolute",
          width: 1,
          height: 1,
          overflow: "hidden",
          clipPath: "inset(50%)",
          whiteSpace: "nowrap",
        }}
      >
        {isDone ? doneLabel : ""}
      </span>
    </div>
  );
}

About this pattern

A delete that asks for a second of deliberate pressure instead of a second dialog. The fill runs linear while the control is held, because the only thing it promises is how much longer this will take, and the rate has to be honest about that. The craft is in the release: an abandoned hold drains from wherever it got to at roughly three times the fill rate, eased out so it leaves quickest at the start. That asymmetry is the entire design — committing is slow and abortable, backing out is immediate and unmistakable, so a press made by accident visibly un-happens. The keyboard gets the same contract rather than a tap-equivalent, the label crossfades at one constant size, and the confirmed ground is a second tint layer fading over the first, since a color-mix value can be faded between but never interpolated.

Delete a workspaceIrreversible account actionWipe a deviceCancel a subscription

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Ridgeline
    Settings
    General
    Notifications
    Members
    Billing
    SettingsNew
    Desktop notificationsAlert on mention and reply
    Weekly digestEvery Monday at 09:00
    SoundsPlay a tone for new messages
    Follow repliesTrack threads you post in
    Settings

    Holding a button to commit, with the ring emptying again the moment it is released.

Related patterns