# Context Menu Peek

> iOS long-press context menu — the bubble lifts, the thread melts into blur, and the menu springs from the press point.

A shadcn/ui block from the [@uicapsule](https://uicapsule.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uicapsule/context-menu-peek).

- Page, with a live preview: https://shadcn.exchange/r/uicapsule/context-menu-peek
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uicapsule/context-menu-peek-light-c7f7a2a6843b26b6.png
- Registry item: https://uicapsule.com/r/context-menu-peek.json
- Categories: navigation, skeuomorphism
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uicapsule.com/r/context-menu-peek.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`, `motion`

## Files

### `/context-menu-peek.tsx`

```tsx
"use client";

import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronLeft, Copy, Languages, Pin, Reply, Trash2, Video } from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";

import { PhoneFrame } from "./phone-frame";

interface Message {
  id: string;
  text: string;
  sent: boolean;
}

const MESSAGES: Message[] = [
  { id: "m0a", sent: false, text: "Morning! Coffee later?" },
  { id: "m0b", sent: true, text: "Always ☕️ 10 at the usual spot" },
  { id: "m0c", sent: false, text: "Perfect. Also —" },
  { id: "m1", sent: false, text: "Did you see the new capsule drop?" },
  { id: "m2", sent: true, text: "The context menu one? Just tried it" },
  { id: "m3", sent: false, text: "Hold any bubble — the whole thread melts away behind it" },
  { id: "m4", sent: true, text: "That lift-then-blur timing is so satisfying" },
  { id: "m5", sent: false, text: "Try holding this one 👀" },
];

interface ActiveBubble {
  id: string;
  menuAbove: boolean;
}

interface MenuAction {
  id: string;
  label: string;
  icon: LucideIcon;
  destructive?: boolean;
}

const MENU_ACTIONS: MenuAction[] = [
  { icon: Reply, id: "reply", label: "Reply" },
  { icon: Copy, id: "copy", label: "Copy" },
  { icon: Languages, id: "translate", label: "Translate" },
  { icon: Pin, id: "pin", label: "Pin" },
  { destructive: true, icon: Trash2, id: "delete", label: "Delete" },
];

const LONG_PRESS_MS = 400;

export const ContextMenuPeek = () => {
  const [active, setActive] = useState<ActiveBubble | null>(null);
  const threadRef = useRef<HTMLDivElement | null>(null);
  const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(
    () => () => {
      if (pressTimerRef.current) {
        clearTimeout(pressTimerRef.current);
      }
    },
    [],
  );

  const startPress = useCallback((id: string, bubble: HTMLElement) => {
    if (pressTimerRef.current) {
      clearTimeout(pressTimerRef.current);
    }
    pressTimerRef.current = setTimeout(() => {
      const thread = threadRef.current?.getBoundingClientRect();
      const rect = bubble.getBoundingClientRect();
      // Bubbles in the lower half have no room below, so the menu flips upward like iOS.
      const menuAbove = thread ? rect.top - thread.top > thread.height / 2 : false;
      setActive({ id, menuAbove });
    }, LONG_PRESS_MS);
  }, []);

  const cancelPress = useCallback(() => {
    if (pressTimerRef.current) {
      clearTimeout(pressTimerRef.current);
      pressTimerRef.current = null;
    }
  }, []);

  const dismiss = useCallback(() => setActive(null), []);

  return (
    <MotionConfig reducedMotion="user">
      <PhoneFrame tone="graphite" className="bg-[#000000]">
        <div className="relative z-10 flex items-center gap-3 border-b border-white/10 bg-[#0d0d0f]/90 px-4 pt-[54px] pb-3">
          <ChevronLeft className="size-5 text-sky-400" />
          <div className="grid size-9 place-items-center rounded-full bg-gradient-to-b from-[#8b93a3] to-[#5b6472] text-[13px] font-semibold text-white">
            KL
          </div>
          <div className="flex-1">
            <p className="text-[14px] font-semibold text-white">Kai Lambert</p>
            <p className="text-[11px] text-white/40">iMessage</p>
          </div>
          <Video className="size-5 text-sky-400" />
        </div>

        <div
          ref={threadRef}
          className="absolute inset-x-0 top-[112px] bottom-[82px] flex flex-col justify-end gap-1.5 px-4 pb-3"
        >
          {MESSAGES.map((message) => {
            const isActive = active?.id === message.id;
            const menuAbove = isActive && active.menuAbove;
            return (
              <div
                key={message.id}
                className={`flex ${message.sent ? "justify-end" : "justify-start"}`}
              >
                <div className={`relative ${isActive ? "z-40" : "z-0"}`}>
                  <motion.div
                    onPointerDown={(event) => startPress(message.id, event.currentTarget)}
                    onPointerUp={cancelPress}
                    onPointerLeave={cancelPress}
                    animate={isActive ? { scale: 1.04, y: -2 } : { scale: 1, y: 0 }}
                    transition={{ bounce: 0.55, duration: 0.35, type: "spring" }}
                    className={`max-w-[220px] cursor-pointer rounded-[18px] px-3.5 py-2 text-[14px] leading-snug ${
                      message.sent ? "bg-[#0a84ff] text-white" : "bg-[#26262a] text-white"
                    } ${isActive ? "shadow-2xl shadow-black/60" : ""}`}
                  >
                    {message.text}
                  </motion.div>

                  <AnimatePresence>
                    {isActive && (
                      <motion.div
                        key="menu"
                        initial={{ opacity: 0, scale: 0.3, y: menuAbove ? 8 : -8 }}
                        animate={{ opacity: 1, scale: 1, y: 0 }}
                        exit={{ opacity: 0, scale: 0.3, y: menuAbove ? 8 : -8 }}
                        transition={{ bounce: 0.3, delay: 0.05, duration: 0.42, type: "spring" }}
                        style={{
                          transformOrigin: `${menuAbove ? "bottom" : "top"} ${message.sent ? "right" : "left"}`,
                        }}
                        className={`absolute z-40 w-56 overflow-hidden rounded-2xl bg-[#1c1c20]/95 shadow-2xl shadow-black/60 ring-1 ring-white/10 backdrop-blur-xl ${
                          message.sent ? "right-0" : "left-0"
                        } ${menuAbove ? "bottom-full mb-2" : "top-full mt-2"}`}
                      >
                        {MENU_ACTIONS.map((action, index) => (
                          <button
                            type="button"
                            key={action.id}
                            onClick={dismiss}
                            className={`flex w-full items-center justify-between px-4 py-2.5 text-[14px] transition-colors hover:bg-white/5 ${
                              action.destructive ? "text-red-400" : "text-white"
                            } ${index > 0 ? "border-t border-white/[0.07]" : ""} ${
                              action.destructive ? "border-t-[6px] border-black/40" : ""
                            }`}
                          >
                            {action.label}
                            <action.icon className="size-4 opacity-70" />
                          </button>
                        ))}
                      </motion.div>
                    )}
                  </AnimatePresence>
                </div>
              </div>
            );
          })}
        </div>

        <AnimatePresence>
          {active && (
            <motion.div
              key="backdrop"
              initial={{ backdropFilter: "blur(0px)", opacity: 0 }}
              animate={{ backdropFilter: "blur(14px)", opacity: 1 }}
              exit={{ backdropFilter: "blur(0px)", opacity: 0 }}
              transition={{ duration: 0.3, ease: "easeOut" }}
              onClick={dismiss}
              onPointerDown={cancelPress}
              className="absolute inset-0 z-30 bg-black/45"
            />
          )}
        </AnimatePresence>

        <div className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 border-t border-white/10 bg-[#0d0d0f]/90 px-4 pt-2.5 pb-9">
          <div className="h-9 flex-1 rounded-full bg-[#1c1c20] px-4 text-[13px] leading-9 text-white/35">
            iMessage
          </div>
        </div>

        <AnimatePresence>
          {!active && (
            <motion.p
              key="hint"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1, transition: { delay: 1 } }}
              exit={{ opacity: 0 }}
              className="pointer-events-none absolute inset-x-0 top-[124px] z-10 text-center text-[11px] text-white/30"
            >
              Hold a bubble
            </motion.p>
          )}
        </AnimatePresence>
      </PhoneFrame>
    </MotionConfig>
  );
};
```

### `/phone-frame.tsx`

```tsx
import type { ReactNode } from "react";

// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.
export const SCREEN_WIDTH = 330;
export const SCREEN_HEIGHT = 716;

interface Clay {
  base: string;
  deep: string;
  light: string;
  /** RGB triplet for inner shading, tinted so each tone reads as one material. */
  rim: string;
}

// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.
const TONES = {
  espresso: { base: "#3a2b24", deep: "#1f1612", light: "#5a4436", rim: "32 20 14" },
  graphite: { base: "#303036", deep: "#18181c", light: "#4a4a52", rim: "10 10 14" },
  moss: { base: "#2f3a2c", deep: "#171d15", light: "#4a5a44", rim: "12 18 10" },
  ochre: { base: "#4a3c20", deep: "#261e0e", light: "#6e5a30", rim: "30 22 6" },
  plum: { base: "#3c2a3a", deep: "#1e141d", light: "#5c4058", rim: "26 12 24" },
  slate: { base: "#2a3448", deep: "#141a26", light: "#435270", rim: "10 16 30" },
  teal: { base: "#233a3a", deep: "#101d1d", light: "#38585a", rim: "6 22 22" },
  terracotta: { base: "#4a2c22", deep: "#26150f", light: "#6c4232", rim: "34 14 8" },
} satisfies Record<string, Clay>;

export type ClayTone = keyof typeof TONES;

const shellShadow = (rim: string) =>
  [
    "0 60px 100px -40px rgb(0 0 0 / 0.85)",
    "0 26px 40px -24px rgb(0 0 0 / 0.7)",
    "inset 0 6px 6px rgb(255 255 255 / 0.16)",
    `inset 0 -14px 22px rgb(${rim} / 0.75)`,
    "inset 12px 0 18px -12px rgb(255 255 255 / 0.1)",
    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,
  ].join(", ");

const Nub = ({ className, clay }: { className: string; clay: Clay }) => (
  <span
    aria-hidden="true"
    className={`absolute w-[9px] rounded-full ${className}`}
    style={{
      background: clay.base,
      boxShadow: `inset 2px 2px 2px rgb(255 255 255 / 0.14), inset -2px -2px 3px rgb(${clay.rim} / 0.7), 0 2px 4px rgb(0 0 0 / 0.5)`,
    }}
  />
);

interface PhoneFrameProps {
  children: ReactNode;
  /** Classes for the screen: background, text color, pointer handling. */
  className?: string;
  /** Hide the Dynamic Island when the screen draws its own status area. */
  island?: boolean;
  tone?: ClayTone;
}

export const PhoneFrame = ({
  children,
  className,
  island = true,
  tone = "graphite",
}: PhoneFrameProps) => {
  const clay = TONES[tone];
  return (
    <div
      className="relative shrink-0 rounded-[66px] p-[16px]"
      style={{
        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,
        boxShadow: shellShadow(clay.rim),
      }}
    >
      <Nub clay={clay} className="top-[132px] -left-[6px] h-[36px]" />
      <Nub clay={clay} className="top-[188px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[262px] -left-[6px] h-[62px]" />
      <Nub clay={clay} className="top-[206px] -right-[6px] h-[92px]" />
      <div
        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? "bg-black"}`}
        style={{
          boxShadow: `0 0 0 2px rgb(${clay.rim} / 0.9), 0 -2px 3px rgb(${clay.rim} / 0.8), 0 3px 4px rgb(255 255 255 / 0.12)`,
          height: SCREEN_HEIGHT,
          width: SCREEN_WIDTH,
        }}
      >
        {children}
        {island && (
          <div
            aria-hidden="true"
            className="pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black"
          />
        )}
      </div>
    </div>
  );
};
```

### `/preview.tsx`

```tsx
"use client";

import { ContextMenuPeek } from "./context-menu-peek";

const Preview = () => (
  <main className="flex h-dvh items-center justify-center overflow-hidden bg-[radial-gradient(ellipse_70%_55%_at_50%_0%,rgb(148_163_184/0.14)_0%,transparent_70%),linear-gradient(180deg,#232830_0%,#171b21_55%,#0f1115_100%)] p-5">
    <ContextMenuPeek />
  </main>
);

export default Preview;
```
