atlas-web
Producción · hace 2 h
Zona de peligro
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.
Settings
Code
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
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Overlays
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.