# Hold-to-delete dialog

> A delete dialog whose button only works if you hold it until it fills. Let go early and it drains back.

- Category: [Overlays](https://library.aimran.es/components/overlays)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/hold-to-confirm-dialog
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## Where to use it

A hold to confirm button makes sense for actions you can't take back: deleting a project on a hosting dashboard, closing an account or wiping a saved cart. The second or so of holding gives people time to think, and a stray click can't trigger it. It works from the keyboard too, by holding Space or Enter.

Don't use it for everyday actions, or it turns into a chore. Around 1.5 seconds is a good hold; past two, people assume it's broken. The project and texts are placeholders, and the actual deleting is still yours to wire up.

## Code

### React (Motion)

```tsx
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
} from "framer-motion";
import { Check, Trash2 } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface HoldToConfirmDialogProps {
  holdMs?: number;
  color?: string;
  label?: string;
  onConfirm?: () => void;
}

export default function HoldToConfirmDialog({
  holdMs = 1500,
  color = "#e5484d",
  label = "Hold to delete",
  onConfirm,
}: HoldToConfirmDialogProps) {
  const [open, setOpen] = useState(false);
  const [holding, setHolding] = useState(false);
  const [done, setDone] = useState(false);
  const [deleted, setDeleted] = useState(false);
  const panel = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const descId = useId();
  const reduce = useReducedMotion();
  const progress = useMotionValue(0);
  const clip = useTransform(progress, (p) => "inset(0 " + (100 - p * 100) + "% 0 0 round 999px)");
  const anim = useRef<AnimationPlaybackControls | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useDialog(panel, open, () => setOpen(false));

  useEffect(() => {
    if (open) return;
    anim.current?.stop();
    progress.set(0);
    setHolding(false);
    setDone(false);
  }, [open, progress]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const start = () => {
    if (done) return;
    setHolding(true);
    anim.current?.stop();
    anim.current = animate(progress, 1, {
      duration: (holdMs / 1000) * (1 - progress.get()),
      ease: "linear",
      onComplete: () => {
        setDone(true);
        setHolding(false);
        navigator.vibrate?.(30);
        onConfirm?.();
        timer.current = setTimeout(() => {
          setOpen(false);
          setDeleted(true);
        }, 800);
      },
    });
  };
  const cancel = () => {
    if (done) return;
    setHolding(false);
    anim.current?.stop();
    anim.current = animate(progress, 0, { type: "spring", stiffness: 260, damping: 30 });
  };
  const text = done ? "Deleted" : holding ? "Keep holding…" : label;
  const Icon = done ? Check : Trash2;

  return (
    <div className="relative grid min-h-[420px] w-full place-items-center bg-[#f5f5f7] p-5 text-[#1d1d1f] dark:bg-[#0b0b0c] dark:text-[#f5f5f7]">
      <div className="w-full max-w-[340px] rounded-[24px] border border-black/[0.06] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_-12px_rgba(0,0,0,0.12)] dark:border-white/[0.08] dark:bg-[#161618] dark:shadow-none">
        <AnimatePresence mode="wait" initial={false}>
          {deleted ? (
            <motion.div
              key="deleted"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              className="flex items-center justify-between gap-3"
            >
              <p className="text-[14px] text-[#86868b]">Project deleted</p>
              <button
                type="button"
                onClick={() => setDeleted(false)}
                className="h-9 rounded-full bg-black/5 px-4 text-[13px] font-medium transition hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
              >
                Undo
              </button>
            </motion.div>
          ) : (
            <motion.div key="project" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}>
              <div className="flex items-center gap-3">
                <span className="grid h-11 w-11 place-items-center rounded-[14px] bg-gradient-to-br from-[#6e8bff] via-[#7c5cff] to-[#c056f5] text-[16px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_6px_16px_-6px_rgba(124,92,255,0.7)]">
                  A
                </span>
                <div className="min-w-0">
                  <p className="text-[16px] font-semibold tracking-[-0.01em]">atlas-web</p>
                  <p className="text-[13px] text-[#86868b]">Production · 2h ago</p>
                </div>
              </div>
              <div className="mt-5 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
                <p className="text-[13px] text-[#86868b]">Danger zone</p>
                <button
                  type="button"
                  aria-haspopup="dialog"
                  onClick={() => setOpen(true)}
                  className="inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[13px] font-medium transition active:scale-[0.97]"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
                >
                  <Trash2 className="h-4 w-4" strokeWidth={1.5} /> Delete
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <AnimatePresence>
        {open && (
          // fixed: el diálogo bloquea toda la ventana
          <motion.div
            key="overlay"
            className="fixed inset-0 z-50 grid place-items-center p-4"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.2 }}
          >
            <div aria-hidden onClick={() => setOpen(false)} className="absolute inset-0 bg-black/25 backdrop-blur-[6px] dark:bg-black/60" />
            <motion.div
              ref={panel}
              role="alertdialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={descId}
              initial={{ opacity: 0, scale: 0.94, y: 12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.97, y: 6 }}
              transition={reduce ? { duration: 0 } : spring}
              className="relative w-full max-w-[360px] rounded-[28px] border border-black/[0.06] bg-white p-5 sm:p-6 text-[#1d1d1f] shadow-[0_10px_30px_rgba(0,0,0,0.12),0_40px_100px_-20px_rgba(0,0,0,0.4)] dark:border-white/10 dark:bg-[#1a1a1d] dark:text-[#f5f5f7]"
            >
              <div className="flex items-center gap-3">
                <span
                  className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 12%, transparent)" }}
                >
                  <Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
                </span>
                <h2 id={titleId} className="text-[18px] font-semibold tracking-[-0.02em]">
                  Delete atlas-web?
                </h2>
              </div>
              <p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
                Deployments, domains, and history will be deleted. This can’t be undone.
              </p>
              <motion.button
                type="button"
                aria-label={label + " (press and hold)"}
                onPointerDown={(e) => {
                  if (e.button === 0) start();
                }}
                onPointerUp={cancel}
                onPointerLeave={cancel}
                onPointerCancel={cancel}
                onContextMenu={(e) => e.preventDefault()}
                onKeyDown={(e) => {
                  if ((e.key === " " || e.key === "Enter") && !e.repeat) {
                    e.preventDefault();
                    start();
                  }
                }}
                onKeyUp={(e) => {
                  if (e.key === " " || e.key === "Enter") cancel();
                }}
                animate={{ scale: holding ? 0.97 : 1 }}
                transition={spring}
                className="mt-6 relative flex h-12 w-full touch-none select-none items-center justify-center gap-2 overflow-hidden rounded-full text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#1a1a1d]"
                style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
              >
                <Icon className="h-4 w-4" strokeWidth={1.5} />
                {text}
                <motion.span
                  aria-hidden
                  className="absolute inset-0 flex items-center justify-center gap-2 text-white"
                  style={{ backgroundColor: color, clipPath: clip }}
                >
                  <Icon className="h-4 w-4" strokeWidth={1.5} />
                  {text}
                </motion.span>
              </motion.button>
              <button
                type="button"
                data-autofocus
                onClick={() => setOpen(false)}
                className="mt-2 h-12 w-full rounded-full text-[14px] font-medium text-[#1d1d1f] transition hover:bg-black/5 dark:text-[#f5f5f7] dark:hover:bg-white/10"
              >
                Cancel
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

## Prompt for AI assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Hold-to-delete dialog” block (Overlays) from AIMRAN Library to my project.

## What it is
A delete dialog whose button only works if you hold it until it fills. Let go early and it drains back.

## Chosen settings
- Hold time: 1500ms (from 600 to 3000ms)
- Danger color: #e5484d
- Button text: "Hold to delete"

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
import { useEffect, useId, useRef, useState, type RefObject } from "react";
import {
  AnimatePresence,
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type AnimationPlaybackControls,
} from "framer-motion";
import { Check, Trash2 } from "lucide-react";

const spring = { type: "spring" as const, stiffness: 380, damping: 34 };

const FOCUSABLE =
  'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])';

// Modal accesible: enfoca el primer control (o el marcado con data-autofocus), atrapa Tab, cierra con Esc y devuelve el foco
function useDialog(ref: RefObject<HTMLElement | null>, open: boolean, onClose: () => void) {
  const close = useRef(onClose);
  useEffect(() => {
    close.current = onClose;
  });
  useEffect(() => {
    if (!open) return;
    const previous = document.activeElement as HTMLElement | null;
    const node = ref.current;
    const first =
      node?.querySelector<HTMLElement>("[data-autofocus]") ?? node?.querySelector<HTMLElement>(FOCUSABLE);
    first?.focus({ preventScroll: true });
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        e.preventDefault();
        close.current();
        return;
      }
      if (e.key !== "Tab" || !node) return;
      const items = Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
      if (!items.length) return;
      const firstItem = items[0];
      const lastItem = items[items.length - 1];
      if (!firstItem || !lastItem) return;
      if (!node.contains(document.activeElement)) {
        e.preventDefault();
        firstItem.focus();
      } else if (e.shiftKey && document.activeElement === firstItem) {
        e.preventDefault();
        lastItem.focus();
      } else if (!e.shiftKey && document.activeElement === lastItem) {
        e.preventDefault();
        firstItem.focus();
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      previous?.focus({ preventScroll: true });
    };
  }, [open, ref]);
}

interface HoldToConfirmDialogProps {
  holdMs?: number;
  color?: string;
  label?: string;
  onConfirm?: () => void;
}

export default function HoldToConfirmDialog({
  holdMs = 1500,
  color = "#e5484d",
  label = "Hold to delete",
  onConfirm,
}: HoldToConfirmDialogProps) {
  const [open, setOpen] = useState(false);
  const [holding, setHolding] = useState(false);
  const [done, setDone] = useState(false);
  const [deleted, setDeleted] = useState(false);
  const panel = useRef<HTMLDivElement>(null);
  const titleId = useId();
  const descId = useId();
  const reduce = useReducedMotion();
  const progress = useMotionValue(0);
  const clip = useTransform(progress, (p) => "inset(0 " + (100 - p * 100) + "% 0 0 round 999px)");
  const anim = useRef<AnimationPlaybackControls | null>(null);
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  useDialog(panel, open, () => setOpen(false));

  useEffect(() => {
    if (open) return;
    anim.current?.stop();
    progress.set(0);
    setHolding(false);
    setDone(false);
  }, [open, progress]);
  useEffect(() => () => clearTimeout(timer.current), []);

  const start = () => {
    if (done) return;
    setHolding(true);
    anim.current?.stop();
    anim.current = animate(progress, 1, {
      duration: (holdMs / 1000) * (1 - progress.get()),
      ease: "linear",
      onComplete: () => {
        setDone(true);
        setHolding(false);
        navigator.vibrate?.(30);
        onConfirm?.();
        timer.current = setTimeout(() => {
          setOpen(false);
          setDeleted(true);
        }, 800);
      },
    });
  };
  const cancel = () => {
    if (done) return;
    setHolding(false);
    anim.current?.stop();
    anim.current = animate(progress, 0, { type: "spring", stiffness: 260, damping: 30 });
  };
  const text = done ? "Deleted" : holding ? "Keep holding…" : label;
  const Icon = done ? Check : Trash2;

  return (
    <div className="relative grid min-h-[420px] w-full place-items-center bg-[#f5f5f7] p-5 text-[#1d1d1f] dark:bg-[#0b0b0c] dark:text-[#f5f5f7]">
      <div className="w-full max-w-[340px] rounded-[24px] border border-black/[0.06] bg-white p-5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_12px_40px_-12px_rgba(0,0,0,0.12)] dark:border-white/[0.08] dark:bg-[#161618] dark:shadow-none">
        <AnimatePresence mode="wait" initial={false}>
          {deleted ? (
            <motion.div
              key="deleted"
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              className="flex items-center justify-between gap-3"
            >
              <p className="text-[14px] text-[#86868b]">Project deleted</p>
              <button
                type="button"
                onClick={() => setDeleted(false)}
                className="h-9 rounded-full bg-black/5 px-4 text-[13px] font-medium transition hover:bg-black/10 dark:bg-white/10 dark:hover:bg-white/15"
              >
                Undo
              </button>
            </motion.div>
          ) : (
            <motion.div key="project" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }}>
              <div className="flex items-center gap-3">
                <span className="grid h-11 w-11 place-items-center rounded-[14px] bg-gradient-to-br from-[#6e8bff] via-[#7c5cff] to-[#c056f5] text-[16px] font-semibold text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.3),0_6px_16px_-6px_rgba(124,92,255,0.7)]">
                  A
                </span>
                <div className="min-w-0">
                  <p className="text-[16px] font-semibold tracking-[-0.01em]">atlas-web</p>
                  <p className="text-[13px] text-[#86868b]">Production · 2h ago</p>
                </div>
              </div>
              <div className="mt-5 flex items-center justify-between gap-3 border-t border-black/[0.06] pt-4 dark:border-white/[0.08]">
                <p className="text-[13px] text-[#86868b]">Danger zone</p>
                <button
                  type="button"
                  aria-haspopup="dialog"
                  onClick={() => setOpen(true)}
                  className="inline-flex h-9 items-center gap-1.5 rounded-full px-4 text-[13px] font-medium transition active:scale-[0.97]"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
                >
                  <Trash2 className="h-4 w-4" strokeWidth={1.5} /> Delete
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>

      <AnimatePresence>
        {open && (
          // fixed: el diálogo bloquea toda la ventana
          <motion.div
            key="overlay"
            className="fixed inset-0 z-50 grid place-items-center p-4"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: reduce ? 0 : 0.2 }}
          >
            <div aria-hidden onClick={() => setOpen(false)} className="absolute inset-0 bg-black/25 backdrop-blur-[6px] dark:bg-black/60" />
            <motion.div
              ref={panel}
              role="alertdialog"
              aria-modal="true"
              aria-labelledby={titleId}
              aria-describedby={descId}
              initial={{ opacity: 0, scale: 0.94, y: 12 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={{ opacity: 0, scale: 0.97, y: 6 }}
              transition={reduce ? { duration: 0 } : spring}
              className="relative w-full max-w-[360px] rounded-[28px] border border-black/[0.06] bg-white p-5 sm:p-6 text-[#1d1d1f] shadow-[0_10px_30px_rgba(0,0,0,0.12),0_40px_100px_-20px_rgba(0,0,0,0.4)] dark:border-white/10 dark:bg-[#1a1a1d] dark:text-[#f5f5f7]"
            >
              <div className="flex items-center gap-3">
                <span
                  className="grid h-10 w-10 shrink-0 place-items-center rounded-full"
                  style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 12%, transparent)" }}
                >
                  <Trash2 className="h-[18px] w-[18px]" strokeWidth={1.5} />
                </span>
                <h2 id={titleId} className="text-[18px] font-semibold tracking-[-0.02em]">
                  Delete atlas-web?
                </h2>
              </div>
              <p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
                Deployments, domains, and history will be deleted. This can’t be undone.
              </p>
              <motion.button
                type="button"
                aria-label={label + " (press and hold)"}
                onPointerDown={(e) => {
                  if (e.button === 0) start();
                }}
                onPointerUp={cancel}
                onPointerLeave={cancel}
                onPointerCancel={cancel}
                onContextMenu={(e) => e.preventDefault()}
                onKeyDown={(e) => {
                  if ((e.key === " " || e.key === "Enter") && !e.repeat) {
                    e.preventDefault();
                    start();
                  }
                }}
                onKeyUp={(e) => {
                  if (e.key === " " || e.key === "Enter") cancel();
                }}
                animate={{ scale: holding ? 0.97 : 1 }}
                transition={spring}
                className="mt-6 relative flex h-12 w-full touch-none select-none items-center justify-center gap-2 overflow-hidden rounded-full text-[14px] font-medium outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-[#1a1a1d]"
                style={{ color, backgroundColor: "color-mix(in srgb, " + color + " 10%, transparent)" }}
              >
                <Icon className="h-4 w-4" strokeWidth={1.5} />
                {text}
                <motion.span
                  aria-hidden
                  className="absolute inset-0 flex items-center justify-center gap-2 text-white"
                  style={{ backgroundColor: color, clipPath: clip }}
                >
                  <Icon className="h-4 w-4" strokeWidth={1.5} />
                  {text}
                </motion.span>
              </motion.button>
              <button
                type="button"
                data-autofocus
                onClick={() => setOpen(false)}
                className="mt-2 h-12 w-full rounded-full text-[14px] font-medium text-[#1d1d1f] transition hover:bg-black/5 dark:text-[#f5f5f7] dark:hover:bg-white/10"
              >
                Cancel
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
