M

Marta

Hoy 9:41

Mensaje
React + MotionOverlays

Nachrichtenmenü per Long Press

Halte eine Chatblase gedrückt: Der Chat verschwimmt, die Nachricht hebt sich ab, und Reaktionen und Aktionen erscheinen.

Einstellungen

450ms
14px

Code

import { useEffect, useRef, useState, type RefObject } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { ArrowUp, Check, Copy, Pin, Reply, Trash2, X } from "lucide-react";

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

type Msg = { id: number; me: boolean; text: string };

const CHAT: Msg[] = [
  { id: 1, me: false, text: "Samstag surfen? 🌊" },
  { id: 2, me: true, text: "Klar! Ich gehe um 9 los" },
  { id: 3, me: false, text: "Ich bring den Kaffee mit ☕" },
  { id: 4, me: true, text: "Halte eine Nachricht gedrückt 👆" },
];

const EMOJIS = ["❤️","👍","😂","😮","🙏"];

type Active = { msg: Msg; x: number; y: number; w: number; h: number; boxW: number; boxH: number };

const REACT_H = 52;
const MENU_H = 184;
const BUBBLE = "rounded-[20px] px-3.5 py-2 text-left text-[15px] leading-snug";
const BUBBLE_THEM = "rounded-bl-md bg-[#e9e9eb] text-[#1d1d1f] dark:bg-[#26262a] dark:text-[#f5f5f7]";
const FLOAT = "bg-white/95 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_12px_36px_rgba(0,0,0,0.18)] backdrop-blur-xl dark:bg-[#2c2c2e]/95";

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 LongPressActionsProps {
  holdMs?: number;
  blur?: number;
  accent?: string;
}

export default function LongPressActions({
  holdMs = 450,
  blur = 14,
  accent = "#0a84ff",
}: LongPressActionsProps) {
  const [messages, setMessages] = useState(CHAT);
  const [reactions, setReactions] = useState<Record<number, string>>({});
  const [pinned, setPinned] = useState<number[]>([]);
  const [replyTo, setReplyTo] = useState<Msg | null>(null);
  const [pressing, setPressing] = useState<number | null>(null);
  const [active, setActive] = useState<Active | null>(null);
  const [copied, setCopied] = useState(false);
  const panel = useRef<HTMLDivElement>(null);
  const bubbles = useRef<Record<number, HTMLButtonElement | null>>({});
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const copyTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
  const origin = useRef({ x: 0, y: 0 });
  const close = () => setActive(null);
  useDialog(panel, !!active, close);
  useEffect(
    () => () => {
      clearTimeout(timer.current);
      clearTimeout(copyTimer.current);
    },
    [],
  );

  const cancelPress = () => {
    clearTimeout(timer.current);
    setPressing(null);
  };
  // Coordenadas de ventana: la capa de acciones es fixed
  const openMenu = (msg: Msg) => {
    cancelPress();
    const el = bubbles.current[msg.id];
    if (!el) return;
    const r = el.getBoundingClientRect();
    // Tamaño sin la escala de "pulsando": si no, la copia elevada saldría estrecha y partiría el texto
    const w = el.offsetWidth + 1;
    const h = el.offsetHeight;
    setActive({
      msg,
      x: r.left + (r.width - w) / 2,
      y: r.top + (r.height - h) / 2,
      w,
      h,
      boxW: window.innerWidth,
      boxH: window.innerHeight,
    });
    navigator.vibrate?.(10);
  };
  const run = (action: string, msg: Msg) => {
    if (action === "reply") setReplyTo(msg);
    if (action === "copy") {
      void navigator.clipboard?.writeText(msg.text).catch(() => {});
      setCopied(true);
      clearTimeout(copyTimer.current);
      copyTimer.current = setTimeout(() => setCopied(false), 1400);
    }
    if (action === "pin") setPinned((p) => (p.includes(msg.id) ? p.filter((id) => id !== msg.id) : [...p, msg.id]));
    if (action === "delete") setMessages((list) => list.filter((m) => m.id !== msg.id));
    close();
  };

  let geo: { start: number; top: number } | null = null;
  if (active) {
    const start = active.y - REACT_H;
    const total = REACT_H + active.h + MENU_H;
    geo = { start, top: Math.max(12, Math.min(start, active.boxH - total - 12)) };
  }
  const actions = [
    { id: "reply", label: "Antworten", icon: Reply },
    { id: "copy", label: "Kopieren", icon: Copy },
    { id: "pin", label: active && pinned.includes(active.msg.id) ? "Lösen" : "Anheften", icon: Pin },
    { id: "delete", label: "Löschen", icon: Trash2 },
  ];

  return (
    <div className="relative flex h-[460px] w-full flex-col items-center justify-center overflow-hidden bg-white text-[#1d1d1f] dark:bg-black dark:text-[#f5f5f7]">
      <div className="flex max-h-full w-full max-w-[420px] flex-col">
        <header className="flex shrink-0 flex-col items-center gap-1 pb-2 pt-4">
          <span className="grid h-11 w-11 place-items-center rounded-full bg-gradient-to-br from-[#fda4af] to-[#f97316] text-[15px] font-semibold text-white shadow-[0_4px_12px_-4px_rgba(249,115,22,0.6)]">
            M
          </span>
          <p className="text-[12px] font-medium">Marta</p>
        </header>

        <div className="flex min-h-0 flex-col-reverse overflow-y-auto px-4 pb-3">
          <div>
            <p className="pb-3 text-center text-[11px] text-[#86868b]">
              <b className="font-semibold">Heute</b> 9:41
            </p>
            <ul className="flex flex-col gap-1.5">
              <AnimatePresence initial={false}>
                {messages.map((m) => (
                  <motion.li
                    key={m.id}
                    layout
                    exit={{ opacity: 0, scale: 0.8 }}
                    transition={spring}
                    className={"flex items-center gap-1.5 " + (m.me ? "flex-row-reverse" : "") + (reactions[m.id] ? " mb-2.5" : "")}
                  >
                    <motion.button
                      ref={(el) => {
                        bubbles.current[m.id] = el;
                      }}
                      type="button"
                      aria-haspopup="dialog"
                      aria-description="Gedrückt halten oder Enter drücken, um Aktionen zu sehen"
                      onPointerDown={(e) => {
                        if (e.button !== 0) return;
                        origin.current = { x: e.clientX, y: e.clientY };
                        setPressing(m.id);
                        clearTimeout(timer.current);
                        timer.current = setTimeout(() => openMenu(m), holdMs);
                      }}
                      onPointerMove={(e) => {
                        if (Math.hypot(e.clientX - origin.current.x, e.clientY - origin.current.y) > 8) cancelPress();
                      }}
                      onPointerUp={cancelPress}
                      onPointerLeave={cancelPress}
                      onPointerCancel={cancelPress}
                      onContextMenu={(e) => {
                        e.preventDefault();
                        openMenu(m);
                      }}
                      onKeyDown={(e) => {
                        if (e.key === "Enter" || e.key === " ") {
                          e.preventDefault();
                          openMenu(m);
                        }
                      }}
                      animate={{ scale: pressing === m.id ? 0.94 : 1, opacity: active?.msg.id === m.id ? 0 : 1 }}
                      transition={pressing === m.id ? { duration: holdMs / 1000, ease: "easeOut" } : spring}
                      className={
                        "relative max-w-[78%] select-none outline-none [-webkit-touch-callout:none] focus-visible:ring-2 focus-visible:ring-[#0071e3] focus-visible:ring-offset-2 " +
                        BUBBLE +
                        (m.me ? " rounded-br-md text-white" : " " + BUBBLE_THEM)
                      }
                      style={m.me ? { backgroundColor: accent } : {}}
                    >
                      {m.text}
                      {reactions[m.id] && (
                        <span
                          className={
                            "absolute -bottom-3 grid h-6 min-w-6 place-items-center rounded-full bg-white px-1 text-[13px] shadow-[0_1px_4px_rgba(0,0,0,0.2)] ring-2 ring-white dark:bg-[#2c2c2e] dark:ring-black " +
                            (m.me ? "-left-2" : "-right-2")
                          }
                        >
                          {reactions[m.id]}
                        </span>
                      )}
                    </motion.button>
                    {pinned.includes(m.id) && <Pin aria-label="Angeheftet" className="h-3.5 w-3.5 shrink-0 text-[#86868b]" strokeWidth={1.5} />}
                  </motion.li>
                ))}
              </AnimatePresence>
            </ul>
          </div>
        </div>

        <div className="shrink-0 px-3 pb-3 pt-1">
          <AnimatePresence initial={false}>
            {replyTo && (
              <motion.div
                key="reply"
                initial={{ height: 0, opacity: 0 }}
                animate={{ height: "auto", opacity: 1 }}
                exit={{ height: 0, opacity: 0 }}
                className="overflow-hidden"
              >
                <div className="mb-2 flex items-center gap-2 rounded-2xl bg-black/5 px-3 py-1.5 text-[12px] dark:bg-white/10">
                  <Reply className="h-3.5 w-3.5 shrink-0" strokeWidth={1.5} style={{ color: accent }} />
                  <span className="min-w-0 flex-1 truncate">
                    <b>{replyTo.me ? "Du" : "Marta"}:</b> {replyTo.text}
                  </span>
                  <button type="button" aria-label="Antwort abbrechen" onClick={() => setReplyTo(null)}>
                    <X className="h-3.5 w-3.5" strokeWidth={1.5} />
                  </button>
                </div>
              </motion.div>
            )}
          </AnimatePresence>
          <div className="flex h-10 items-center rounded-full border border-black/10 pl-4 pr-1 text-[14px] text-[#86868b] dark:border-white/15">
            <span className="flex-1">Nachricht</span>
            <span aria-hidden className="grid h-8 w-8 place-items-center rounded-full text-white" style={{ backgroundColor: accent }}>
              <ArrowUp className="h-4 w-4" strokeWidth={2} />
            </span>
          </div>
        </div>
      </div>

      <div className="pointer-events-none fixed inset-x-0 top-16 z-[60] flex justify-center" role="status">
        <AnimatePresence>
          {copied && (
            <motion.span
              key="copied"
              initial={{ opacity: 0, y: -8, scale: 0.95 }}
              animate={{ opacity: 1, y: 0, scale: 1 }}
              exit={{ opacity: 0, y: -8 }}
              className="flex items-center gap-1.5 rounded-full bg-[#1d1d1f] px-3 py-1.5 text-[12px] text-white dark:bg-white dark:text-black"
            >
              <Check className="h-3.5 w-3.5" strokeWidth={2} /> Kopiert
            </motion.span>
          )}
        </AnimatePresence>
      </div>

      <AnimatePresence>
        {active && geo && (
          // fixed: el desenfoque cubre toda la ventana, como el menú de pulsación larga de iOS
          <motion.div
            key="actions"
            ref={panel}
            role="dialog"
            aria-modal="true"
            aria-label="Nachrichtenaktionen"
            className="fixed inset-0 z-50"
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.18 }}
          >
            <div
              aria-hidden
              onClick={close}
              className="absolute inset-0 bg-white/40 dark:bg-black/45"
              style={{ backdropFilter: "blur(" + blur + "px) saturate(160%)" }}
            />
            <motion.div
              className={"absolute flex flex-col gap-2 " + (active.msg.me ? "items-end" : "items-start")}
              style={active.msg.me ? { right: active.boxW - active.x - active.w } : { left: active.x }}
              initial={{ top: geo.start }}
              animate={{ top: geo.top }}
              transition={spring}
            >
              <motion.div
                role="group"
                aria-label="Reaktionen"
                initial={{ opacity: 0, scale: 0.6, y: 10 }}
                animate={{ opacity: 1, scale: 1, y: 0 }}
                transition={spring}
                style={{ transformOrigin: active.msg.me ? "right bottom" : "left bottom" }}
                className={"flex h-11 items-center gap-0.5 rounded-full px-1.5 " + FLOAT}
              >
                {EMOJIS.map((emoji, i) => (
                  <motion.button
                    key={emoji}
                    type="button"
                    aria-label={"Reagieren mit " + emoji}
                    aria-pressed={reactions[active.msg.id] === emoji}
                    onClick={() => {
                      setReactions((r) => ({ ...r, [active.msg.id]: emoji }));
                      close();
                    }}
                    initial={{ scale: 0 }}
                    animate={{ scale: 1 }}
                    transition={{ ...spring, delay: 0.04 + i * 0.03 }}
                    whileHover={{ scale: 1.25, y: -3 }}
                    whileTap={{ scale: 0.9 }}
                    className={
                      "grid h-8 w-8 place-items-center rounded-full text-[20px] " +
                      (reactions[active.msg.id] === emoji ? "bg-black/10 dark:bg-white/15" : "")
                    }
                  >
                    {emoji}
                  </motion.button>
                ))}
              </motion.div>
              <motion.div
                aria-hidden
                initial={{ scale: 1 }}
                animate={{ scale: 1.04 }}
                transition={spring}
                style={{ width: active.w, backgroundColor: active.msg.me ? accent : undefined }}
                className={BUBBLE + " shadow-[0_12px_32px_rgba(0,0,0,0.2)] " + (active.msg.me ? "rounded-br-md text-white" : BUBBLE_THEM)}
              >
                {active.msg.text}
              </motion.div>
              <motion.div
                role="menu"
                initial={{ opacity: 0, scale: 0.8 }}
                animate={{ opacity: 1, scale: 1 }}
                transition={spring}
                style={{ transformOrigin: active.msg.me ? "right top" : "left top" }}
                className={"w-52 overflow-hidden rounded-2xl py-1 " + FLOAT}
              >
                {actions.map(({ id, label, icon: Icon }, i) => (
                  <button
                    key={id}
                    type="button"
                    role="menuitem"
                    data-autofocus={i === 0 ? true : undefined}
                    onClick={() => run(id, active.msg)}
                    className={
                      "flex h-10 w-full items-center justify-between px-4 text-[15px] outline-none transition hover:bg-black/5 focus-visible:bg-black/5 dark:hover:bg-white/10 dark:focus-visible:bg-white/10 " +
                      (id === "delete" ? "text-[#ff3b30]" : "")
                    }
                  >
                    {label}
                    <Icon className="h-4 w-4" strokeWidth={1.5} />
                  </button>
                ))}
              </motion.div>
            </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

Dieses Kontextmenü per langem Drücken kennt man aus Messengern wie WhatsApp oder iMessage. Es passt also zu einer Chatfunktion, zu den Kommentaren einer Community-App oder zum Support-Chat eines Onlineshops. Antworten, Kopieren, Anheften und Löschen funktionieren schon in der Demo, und Emoji-Reaktionen bleiben an der Blase hängen.

Am Rechner öffnet es sich per Rechtsklick oder Enter, niemand bleibt also außen vor. Halte die Druckdauer kurz, etwa eine halbe Sekunde, sonst wirkt es träge. Die Unschärfe im kopierten Code deckt das ganze Fenster ab, und der Chatverlauf ist nur ein Beispiel.