# Long-press message menu

> Press and hold a chat bubble: the conversation blurs, the message lifts and reactions and actions pop up around it.

- Category: [Overlays](https://library.aimran.es/components/overlays)
- Built with: React + Motion
- Live editor: https://library.aimran.es/blocks/long-press-actions
- Library: AIMRAN Library (https://library.aimran.es), free, no sign-up, by Aimran Oukham

## 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.

## Code

### React (Motion)

```tsx
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 assistants

Paste this into ChatGPT, Claude, Cursor, v0 or Lovable to adapt the block to your stack (Tailwind, Vue, Svelte…):

````text
I want to add the “Long-press message menu” block (Overlays) from AIMRAN Library to my project.

## What it is
Press and hold a chat bubble: the conversation blurs, the message lifts and reactions and actions pop up around it.

## Chosen settings
- Hold time: 450ms (from 250 to 1000ms)
- Blur: 14px (from 4 to 24px)
- Your message color: #0a84ff

## What I need
1. Add it to my project with the same look and animation as the reference code: same sizes, colours, radii, shadows, timings and easing.
2. It is built with React + TypeScript, with framer-motion for the animation and lucide-react for the icons. If my project uses something else (Vue, Svelte, Astro, plain HTML…), translate it without losing any behaviour. If I use Tailwind, turn the styles into classes; if not, keep the CSS in its own file.
3. Expose the settings above as props (or CSS variables) with those values as defaults.
4. Make it work well on phones: nothing that depends only on hover, touch targets of at least 44 px and no overflow on 320 px screens.
5. Make it look right in light and dark mode.
6. Keep it accessible: semantic HTML, roles and aria-* where needed, visible focus and keyboard support.
7. Respect prefers-reduced-motion: with reduced motion, no big animations but the same end result.

## Reference code
```tsx
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>
  );
}
```

Give me the complete final code, ready to paste, and tell me in one line where to put it in my project.
````
