Marta
Hoy 9:41
Long-press message menu
Press and hold a chat bubble: the conversation blurs, the message lifts and reactions and actions pop up around it.
Settings
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: "Surfing on Saturday? 🌊" },
{ id: 2, me: true, text: "Sure! Leaving at 9" },
{ id: 3, me: false, text: "I’ll bring the coffee ☕" },
{ id: 4, me: true, text: "Press and hold a message 👆" },
];
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: "Reply", icon: Reply },
{ id: "copy", label: "Copy", icon: Copy },
{ id: "pin", label: active && pinned.includes(active.msg.id) ? "Unpin" : "Pin", icon: Pin },
{ id: "delete", label: "Delete", 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">Today</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="Press and hold, or press Enter, to see actions"
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="Pinned" 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 ? "You" : "Marta"}:</b> {replyTo.text}
</span>
<button type="button" aria-label="Cancel reply" 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">Message</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} /> Copied
</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="Message actions"
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="Reactions"
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={"React with " + 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 for AI
Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.
More in Overlays
Where to use it
This long press context menu is what people expect from a messaging app like WhatsApp or iMessage, so it fits a chat feature, the comments of a community app or the support chat of an online shop. Reply, copy, pin and delete all work in the demo, and emoji reactions stick to the bubble.
On a computer it opens with a right click or Enter, so nobody is left out. Keep the hold short, around half a second, or it feels sluggish. The blur in the copied code covers the whole window, and the conversation is a sample to swap for yours.