M

Marta

Hoy 9:41

Mensaje
React + MotionOverlays

Pulsación larga en el chat

Mantén pulsado un mensaje: el chat se desenfoca, la burbuja se eleva y aparecen las reacciones y las acciones.

Ajustes

450ms
14px

Código

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: "¿Surf el sábado? 🌊" },
  { id: 2, me: true, text: "¡Claro! Salgo a las 9" },
  { id: 3, me: false, text: "Yo llevo el café ☕" },
  { id: 4, me: true, text: "Mantén pulsado un mensaje 👆" },
];

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: "Responder", icon: Reply },
    { id: "copy", label: "Copiar", icon: Copy },
    { id: "pin", label: active && pinned.includes(active.msg.id) ? "Desfijar" : "Fijar", icon: Pin },
    { id: "delete", label: "Eliminar", 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">Hoy</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="Mantén pulsado o pulsa Intro para ver acciones"
                      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="Fijado" 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 ? "Tú" : "Marta"}:</b> {replyTo.text}
                  </span>
                  <button type="button" aria-label="Cancelar respuesta" 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">Mensaje</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} /> Copiado
            </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="Acciones del mensaje"
            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="Reacciones"
                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={"Reaccionar con " + 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 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

Este menú contextual con pulsación larga es lo que la gente espera de apps como WhatsApp o iMessage, así que encaja en un chat, en los comentarios de una app de comunidad o en el chat de soporte de una tienda online. Responder, copiar, fijar y eliminar funcionan en la demo, y las reacciones se quedan en la burbuja.

En el ordenador se abre con clic derecho o con Intro, así que nadie se queda fuera. Deja la pulsación corta, cerca de medio segundo, o parecerá lenta. El desenfoque del código copiable cubre toda la ventana, y la conversación es de ejemplo para cambiarla por la tuya.