Marta
Hoy 9:41
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
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.