A

atlas-web

Producción · hace 2 h

Zona de peligro

React + MotionOverlays

Löschen durch Gedrückthalten

Ein Löschdialog, dessen Button erst auslöst, wenn du ihn gedrückt hältst, bis er voll ist. Lässt du früher los, leert er sich.

Einstellungen

1500ms

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 = "Zum Löschen gedrückt halten",
  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 ? "Gelöscht" : holding ? "Weiter gedrückt halten…" : 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]">Projekt gelöscht</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"
              >
                Widerrufen
              </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]">Produktion · vor 2 Std.</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]">Gefahrenzone</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} /> Löschen
                </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]">
                  atlas-web löschen?
                </h2>
              </div>
              <p id={descId} className="mt-3 text-[14px] leading-relaxed text-[#6e6e73] dark:text-[#a1a1a6]">
                Deployments, Domains und Verlauf werden gelöscht. Das lässt sich nicht rückgängig machen.
              </p>
              <motion.button
                type="button"
                aria-label={label + " (gedrückt halten)"}
                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"
              >
                Abbrechen
              </button>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

Prompt für KI

Füg ihn in ChatGPT, Claude oder Cursor ein und der Baustein wird mit diesen Einstellungen an dein Projekt angepasst, auch ohne React.

Mehr aus Overlays

Wofür du ihn nutzen kannst

Ein Button zum Gedrückthalten lohnt sich bei Aktionen, die sich nicht rückgängig machen lassen: ein Projekt im Hosting-Dashboard löschen, ein Konto schließen oder einen gespeicherten Warenkorb leeren. Die anderthalb Sekunden geben Zeit zum Nachdenken, und ein versehentlicher Klick reicht nicht. Mit der Tastatur geht es auch, per Leertaste oder Enter.

Für alltägliche Aktionen ist das zu umständlich. Anderthalb Sekunden sind ein gutes Maß, ab zwei Sekunden denken viele, es klemmt. Projekt und Texte sind Beispiele, und das eigentliche Löschen musst du noch selbst anbinden.