{"$schema":"https://ui.shadcn.com/schema/registry.json","author":"Kaiyu Hsu <uicapsule@kyh.io>","categories":["navigation","skeuomorphism"],"dependencies":["lucide-react","motion"],"description":"iOS long-press context menu — the bubble lifts, the thread melts into blur, and the menu springs from the press point.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { ChevronLeft, Copy, Languages, Pin, Reply, Trash2, Video } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport { AnimatePresence, MotionConfig, motion } from \"motion/react\";\n\nimport { PhoneFrame } from \"./phone-frame\";\n\ninterface Message {\n  id: string;\n  text: string;\n  sent: boolean;\n}\n\nconst MESSAGES: Message[] = [\n  { id: \"m0a\", sent: false, text: \"Morning! Coffee later?\" },\n  { id: \"m0b\", sent: true, text: \"Always ☕️ 10 at the usual spot\" },\n  { id: \"m0c\", sent: false, text: \"Perfect. Also —\" },\n  { id: \"m1\", sent: false, text: \"Did you see the new capsule drop?\" },\n  { id: \"m2\", sent: true, text: \"The context menu one? Just tried it\" },\n  { id: \"m3\", sent: false, text: \"Hold any bubble — the whole thread melts away behind it\" },\n  { id: \"m4\", sent: true, text: \"That lift-then-blur timing is so satisfying\" },\n  { id: \"m5\", sent: false, text: \"Try holding this one 👀\" },\n];\n\ninterface ActiveBubble {\n  id: string;\n  menuAbove: boolean;\n}\n\ninterface MenuAction {\n  id: string;\n  label: string;\n  icon: LucideIcon;\n  destructive?: boolean;\n}\n\nconst MENU_ACTIONS: MenuAction[] = [\n  { icon: Reply, id: \"reply\", label: \"Reply\" },\n  { icon: Copy, id: \"copy\", label: \"Copy\" },\n  { icon: Languages, id: \"translate\", label: \"Translate\" },\n  { icon: Pin, id: \"pin\", label: \"Pin\" },\n  { destructive: true, icon: Trash2, id: \"delete\", label: \"Delete\" },\n];\n\nconst LONG_PRESS_MS = 400;\n\nexport const ContextMenuPeek = () => {\n  const [active, setActive] = useState<ActiveBubble | null>(null);\n  const threadRef = useRef<HTMLDivElement | null>(null);\n  const pressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n  useEffect(\n    () => () => {\n      if (pressTimerRef.current) {\n        clearTimeout(pressTimerRef.current);\n      }\n    },\n    [],\n  );\n\n  const startPress = useCallback((id: string, bubble: HTMLElement) => {\n    if (pressTimerRef.current) {\n      clearTimeout(pressTimerRef.current);\n    }\n    pressTimerRef.current = setTimeout(() => {\n      const thread = threadRef.current?.getBoundingClientRect();\n      const rect = bubble.getBoundingClientRect();\n      // Bubbles in the lower half have no room below, so the menu flips upward like iOS.\n      const menuAbove = thread ? rect.top - thread.top > thread.height / 2 : false;\n      setActive({ id, menuAbove });\n    }, LONG_PRESS_MS);\n  }, []);\n\n  const cancelPress = useCallback(() => {\n    if (pressTimerRef.current) {\n      clearTimeout(pressTimerRef.current);\n      pressTimerRef.current = null;\n    }\n  }, []);\n\n  const dismiss = useCallback(() => setActive(null), []);\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <PhoneFrame tone=\"graphite\" className=\"bg-[#000000]\">\n        <div className=\"relative z-10 flex items-center gap-3 border-b border-white/10 bg-[#0d0d0f]/90 px-4 pt-[54px] pb-3\">\n          <ChevronLeft className=\"size-5 text-sky-400\" />\n          <div className=\"grid size-9 place-items-center rounded-full bg-gradient-to-b from-[#8b93a3] to-[#5b6472] text-[13px] font-semibold text-white\">\n            KL\n          </div>\n          <div className=\"flex-1\">\n            <p className=\"text-[14px] font-semibold text-white\">Kai Lambert</p>\n            <p className=\"text-[11px] text-white/40\">iMessage</p>\n          </div>\n          <Video className=\"size-5 text-sky-400\" />\n        </div>\n\n        <div\n          ref={threadRef}\n          className=\"absolute inset-x-0 top-[112px] bottom-[82px] flex flex-col justify-end gap-1.5 px-4 pb-3\"\n        >\n          {MESSAGES.map((message) => {\n            const isActive = active?.id === message.id;\n            const menuAbove = isActive && active.menuAbove;\n            return (\n              <div\n                key={message.id}\n                className={`flex ${message.sent ? \"justify-end\" : \"justify-start\"}`}\n              >\n                <div className={`relative ${isActive ? \"z-40\" : \"z-0\"}`}>\n                  <motion.div\n                    onPointerDown={(event) => startPress(message.id, event.currentTarget)}\n                    onPointerUp={cancelPress}\n                    onPointerLeave={cancelPress}\n                    animate={isActive ? { scale: 1.04, y: -2 } : { scale: 1, y: 0 }}\n                    transition={{ bounce: 0.55, duration: 0.35, type: \"spring\" }}\n                    className={`max-w-[220px] cursor-pointer rounded-[18px] px-3.5 py-2 text-[14px] leading-snug ${\n                      message.sent ? \"bg-[#0a84ff] text-white\" : \"bg-[#26262a] text-white\"\n                    } ${isActive ? \"shadow-2xl shadow-black/60\" : \"\"}`}\n                  >\n                    {message.text}\n                  </motion.div>\n\n                  <AnimatePresence>\n                    {isActive && (\n                      <motion.div\n                        key=\"menu\"\n                        initial={{ opacity: 0, scale: 0.3, y: menuAbove ? 8 : -8 }}\n                        animate={{ opacity: 1, scale: 1, y: 0 }}\n                        exit={{ opacity: 0, scale: 0.3, y: menuAbove ? 8 : -8 }}\n                        transition={{ bounce: 0.3, delay: 0.05, duration: 0.42, type: \"spring\" }}\n                        style={{\n                          transformOrigin: `${menuAbove ? \"bottom\" : \"top\"} ${message.sent ? \"right\" : \"left\"}`,\n                        }}\n                        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 ${\n                          message.sent ? \"right-0\" : \"left-0\"\n                        } ${menuAbove ? \"bottom-full mb-2\" : \"top-full mt-2\"}`}\n                      >\n                        {MENU_ACTIONS.map((action, index) => (\n                          <button\n                            type=\"button\"\n                            key={action.id}\n                            onClick={dismiss}\n                            className={`flex w-full items-center justify-between px-4 py-2.5 text-[14px] transition-colors hover:bg-white/5 ${\n                              action.destructive ? \"text-red-400\" : \"text-white\"\n                            } ${index > 0 ? \"border-t border-white/[0.07]\" : \"\"} ${\n                              action.destructive ? \"border-t-[6px] border-black/40\" : \"\"\n                            }`}\n                          >\n                            {action.label}\n                            <action.icon className=\"size-4 opacity-70\" />\n                          </button>\n                        ))}\n                      </motion.div>\n                    )}\n                  </AnimatePresence>\n                </div>\n              </div>\n            );\n          })}\n        </div>\n\n        <AnimatePresence>\n          {active && (\n            <motion.div\n              key=\"backdrop\"\n              initial={{ backdropFilter: \"blur(0px)\", opacity: 0 }}\n              animate={{ backdropFilter: \"blur(14px)\", opacity: 1 }}\n              exit={{ backdropFilter: \"blur(0px)\", opacity: 0 }}\n              transition={{ duration: 0.3, ease: \"easeOut\" }}\n              onClick={dismiss}\n              onPointerDown={cancelPress}\n              className=\"absolute inset-0 z-30 bg-black/45\"\n            />\n          )}\n        </AnimatePresence>\n\n        <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\">\n          <div className=\"h-9 flex-1 rounded-full bg-[#1c1c20] px-4 text-[13px] leading-9 text-white/35\">\n            iMessage\n          </div>\n        </div>\n\n        <AnimatePresence>\n          {!active && (\n            <motion.p\n              key=\"hint\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: { delay: 1 } }}\n              exit={{ opacity: 0 }}\n              className=\"pointer-events-none absolute inset-x-0 top-[124px] z-10 text-center text-[11px] text-white/30\"\n            >\n              Hold a bubble\n            </motion.p>\n          )}\n        </AnimatePresence>\n      </PhoneFrame>\n    </MotionConfig>\n  );\n};\n","path":"/context-menu-peek.tsx","target":"uicapsule/context-menu-peek/context-menu-peek.tsx","type":"registry:file"},{"content":"import type { ReactNode } from \"react\";\n\n// Screen is 330×716 (iPhone 15 proportions); the clay shell adds a 16px lip on every side.\nexport const SCREEN_WIDTH = 330;\nexport const SCREEN_HEIGHT = 716;\n\ninterface Clay {\n  base: string;\n  deep: string;\n  light: string;\n  /** RGB triplet for inner shading, tinted so each tone reads as one material. */\n  rim: string;\n}\n\n// Dark matte clay: `light` is the lit top face, `base` the body, `deep` the shaded underside.\nconst TONES = {\n  espresso: { base: \"#3a2b24\", deep: \"#1f1612\", light: \"#5a4436\", rim: \"32 20 14\" },\n  graphite: { base: \"#303036\", deep: \"#18181c\", light: \"#4a4a52\", rim: \"10 10 14\" },\n  moss: { base: \"#2f3a2c\", deep: \"#171d15\", light: \"#4a5a44\", rim: \"12 18 10\" },\n  ochre: { base: \"#4a3c20\", deep: \"#261e0e\", light: \"#6e5a30\", rim: \"30 22 6\" },\n  plum: { base: \"#3c2a3a\", deep: \"#1e141d\", light: \"#5c4058\", rim: \"26 12 24\" },\n  slate: { base: \"#2a3448\", deep: \"#141a26\", light: \"#435270\", rim: \"10 16 30\" },\n  teal: { base: \"#233a3a\", deep: \"#101d1d\", light: \"#38585a\", rim: \"6 22 22\" },\n  terracotta: { base: \"#4a2c22\", deep: \"#26150f\", light: \"#6c4232\", rim: \"34 14 8\" },\n} satisfies Record<string, Clay>;\n\nexport type ClayTone = keyof typeof TONES;\n\nconst shellShadow = (rim: string) =>\n  [\n    \"0 60px 100px -40px rgb(0 0 0 / 0.85)\",\n    \"0 26px 40px -24px rgb(0 0 0 / 0.7)\",\n    \"inset 0 6px 6px rgb(255 255 255 / 0.16)\",\n    `inset 0 -14px 22px rgb(${rim} / 0.75)`,\n    \"inset 12px 0 18px -12px rgb(255 255 255 / 0.1)\",\n    `inset -12px 0 18px -12px rgb(${rim} / 0.6)`,\n  ].join(\", \");\n\nconst Nub = ({ className, clay }: { className: string; clay: Clay }) => (\n  <span\n    aria-hidden=\"true\"\n    className={`absolute w-[9px] rounded-full ${className}`}\n    style={{\n      background: clay.base,\n      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)`,\n    }}\n  />\n);\n\ninterface PhoneFrameProps {\n  children: ReactNode;\n  /** Classes for the screen: background, text color, pointer handling. */\n  className?: string;\n  /** Hide the Dynamic Island when the screen draws its own status area. */\n  island?: boolean;\n  tone?: ClayTone;\n}\n\nexport const PhoneFrame = ({\n  children,\n  className,\n  island = true,\n  tone = \"graphite\",\n}: PhoneFrameProps) => {\n  const clay = TONES[tone];\n  return (\n    <div\n      className=\"relative shrink-0 rounded-[66px] p-[16px]\"\n      style={{\n        background: `linear-gradient(165deg, ${clay.light} 0%, ${clay.base} 48%, ${clay.deep} 100%)`,\n        boxShadow: shellShadow(clay.rim),\n      }}\n    >\n      <Nub clay={clay} className=\"top-[132px] -left-[6px] h-[36px]\" />\n      <Nub clay={clay} className=\"top-[188px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[262px] -left-[6px] h-[62px]\" />\n      <Nub clay={clay} className=\"top-[206px] -right-[6px] h-[92px]\" />\n      <div\n        className={`relative isolate overflow-hidden rounded-[50px] select-none ${className ?? \"bg-black\"}`}\n        style={{\n          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)`,\n          height: SCREEN_HEIGHT,\n          width: SCREEN_WIDTH,\n        }}\n      >\n        {children}\n        {island && (\n          <div\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute top-[11px] left-1/2 z-50 h-[30px] w-[98px] -translate-x-1/2 rounded-full bg-black\"\n          />\n        )}\n      </div>\n    </div>\n  );\n};\n","path":"/phone-frame.tsx","target":"uicapsule/context-menu-peek/phone-frame.tsx","type":"registry:file"},{"content":"\"use client\";\n\nimport { ContextMenuPeek } from \"./context-menu-peek\";\n\nconst Preview = () => (\n  <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\">\n    <ContextMenuPeek />\n  </main>\n);\n\nexport default Preview;\n","path":"/preview.tsx","target":"uicapsule/context-menu-peek/preview.tsx","type":"registry:file"}],"homepage":"https://www.uicapsule.com/ui/context-menu-peek","name":"context-menu-peek","registryDependencies":[],"title":"Context Menu Peek","type":"registry:block"}