A

atlas-web

Producción · hace 2 h

Zona de peligro

React + MotionOverlays

Confirmar manteniendo pulsado

Un diálogo de borrado cuyo botón solo actúa si lo mantienes pulsado hasta que se llena. Si sueltas antes, se vacía.

Ajustes

1500ms

Código

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 = "Mantén para eliminar",
  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 ? "Eliminado" : holding ? "Sigue pulsando…" : 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]">Proyecto eliminado</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"
              >
                Deshacer
              </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]">Producción · hace 2 h</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]">Zona de peligro</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} /> Eliminar
                </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]">
                  ¿Eliminar atlas-web?
                </h2>
              </div>
              <p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
                Se borrarán los despliegues, los dominios y el historial. No se puede deshacer.
              </p>
              <motion.button
                type="button"
                aria-label={label + " (mantén pulsado)"}
                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"
              >
                Cancelar
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Overlays

Dónde usarlo

Un botón de mantener pulsado para confirmar tiene sentido en lo que no se puede deshacer: borrar un proyecto en un panel de hosting, cerrar una cuenta o vaciar un carrito guardado. Ese segundo y medio da tiempo a pensarlo y evita el clic despistado. También funciona con teclado, manteniendo Espacio o Intro.

No lo pongas en acciones del día a día o se vuelve un fastidio. Con un segundo y medio basta; por encima de dos, la gente cree que no funciona. El proyecto y los textos son de ejemplo, y el borrado de verdad lo conectas tú.