{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"bloom-menu","type":"registry:block","title":"Bloom Menu","description":"A button that morphs open into a menu and blooms iris-out from the center, the grid revealing in every direction with radially staggered items.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/bloom-menu.tsx","type":"registry:component","target":"@components/motion/bloom-menu.tsx","content":"\"use client\";\n\nimport {\n  Bell,\n  FileText,\n  FolderClosed,\n  LayoutGrid,\n  Link,\n  Plus,\n  Table,\n  X,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type ComponentType, useEffect, useId, useRef, useState } from \"react\";\nimport { EASE_OUT } from \"@/lib/ease\";\nimport { cn } from \"@/lib/utils\";\n\ntype MenuItem = { label: string; icon: ComponentType<{ className?: string }> };\n\nconst ITEMS: MenuItem[] = [\n  { label: \"Doc\", icon: FileText },\n  { label: \"Board\", icon: LayoutGrid },\n  { label: \"Table\", icon: Table },\n  { label: \"Folder\", icon: FolderClosed },\n  { label: \"Reminder\", icon: Bell },\n  { label: \"Link\", icon: Link },\n];\n\n// Folder-open feel: a touch of overshoot as the panel expands, kept subtle.\nconst SPRING_FOLDER = {\n  type: \"spring\",\n  stiffness: 300,\n  damping: 32,\n  mass: 0.9,\n} as const;\n\nexport interface BloomMenuProps {\n  items?: MenuItem[];\n  onSelect?: (label: string) => void;\n  className?: string;\n}\n\nexport function BloomMenu({\n  items = ITEMS,\n  onSelect,\n  className,\n}: BloomMenuProps) {\n  const [open, setOpen] = useState(false);\n  const reduce = useReducedMotion();\n  const layoutId = useId();\n  const ref = useRef<HTMLDivElement>(null);\n\n  useEffect(() => {\n    if (!open) return;\n    const onKey = (e: KeyboardEvent) => {\n      if (e.key === \"Escape\") setOpen(false);\n    };\n    const onPointer = (e: PointerEvent) => {\n      if (ref.current && !ref.current.contains(e.target as Node))\n        setOpen(false);\n    };\n    window.addEventListener(\"keydown\", onKey);\n    window.addEventListener(\"pointerdown\", onPointer);\n    return () => {\n      window.removeEventListener(\"keydown\", onKey);\n      window.removeEventListener(\"pointerdown\", onPointer);\n    };\n  }, [open]);\n\n  const morph = reduce ? { duration: 0.15 } : SPRING_FOLDER;\n\n  return (\n    <div ref={ref} className={cn(\"relative inline-flex\", className)}>\n      {/* spacer fixes the anchor to the trigger size */}\n      <div className=\"h-11 w-36\" aria-hidden />\n\n      {/* Centering box sized to the OPEN panel and centered on the trigger.\n          place-items-center only centers an item that fits its cell, so the cell\n          must be as wide as the panel — otherwise the overflow left-anchors and\n          the panel expands rightward. The box is a fixed size per viewport (vw\n          doesn't change mid-animation), so its -translate centering never drifts\n          the way a content-sized wrapper would. Both states share its center, so\n          the morph grows from the middle outward in every direction. */}\n      <div className=\"pointer-events-none absolute left-1/2 top-1/2 z-30 grid h-[300px] w-[min(86vw,420px)] -translate-x-1/2 -translate-y-1/2 place-items-center [&>*]:pointer-events-auto\">\n        {/* popLayout pulls the exiting trigger out of grid flow at once, so the\n            grid never briefly holds two rows and shoves the panel off-center */}\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {open ? (\n            <motion.div\n              key=\"panel\"\n              layoutId={layoutId}\n              transition={morph}\n              style={{ borderRadius: 16 }}\n              className=\"w-[min(86vw,420px)] overflow-hidden border border-border bg-card\"\n            >\n              <motion.div\n                // `layout` lets framer undo the box's morph scaling so this\n                // content stays crisp instead of stretching with the resize.\n                layout\n                initial={{ opacity: 0 }}\n                animate={{ opacity: 1 }}\n                transition={{ delay: reduce ? 0 : 0.12, duration: 0.2 }}\n              >\n                {/* header */}\n                <div className=\"flex items-center justify-between border-b border-border px-4 py-3\">\n                  <span className=\"text-sm font-medium text-muted-foreground\">\n                    Create\n                  </span>\n                  <button\n                    type=\"button\"\n                    onClick={() => setOpen(false)}\n                    aria-label=\"Close menu\"\n                    className=\"text-muted-foreground transition-colors hover:text-foreground\"\n                  >\n                    <X className=\"h-4 w-4\" />\n                  </button>\n                </div>\n\n                {/* grid */}\n                <motion.div\n                  // Iris reveal: start as a small box at the grid center and open\n                  // outward to all four corners, so the menu grows from the middle\n                  // in every direction instead of wiping top-down.\n                  initial={\n                    reduce ? false : { clipPath: \"inset(45% 34% 45% 34%)\" }\n                  }\n                  animate={{ clipPath: \"inset(0% 0% 0% 0%)\" }}\n                  transition={{\n                    delay: reduce ? 0 : 0.08,\n                    duration: 0.45,\n                    ease: EASE_OUT,\n                  }}\n                  className=\"grid grid-cols-3\"\n                >\n                  {items.map((item, i) => {\n                    // Radial stagger: delay each item by its distance from the\n                    // grid center so the four corners animate together and the\n                    // open reads as center-out, not corner-by-corner.\n                    const cols = 3;\n                    const rows = Math.ceil(items.length / cols);\n                    const col = i % cols;\n                    const row = Math.floor(i / cols);\n                    const dist = Math.hypot(\n                      col - (cols - 1) / 2,\n                      row - (rows - 1) / 2,\n                    );\n                    return (\n                      <button\n                        key={item.label}\n                        type=\"button\"\n                        onClick={() => {\n                          onSelect?.(item.label);\n                          setOpen(false);\n                        }}\n                      // Static cell with hairline borders (no animated fill) so\n                      // the grid lines never flicker as items stagger in. Only the\n                      // inner content animates.\n                      className={cn(\n                        \"flex items-center justify-center px-3 py-6 text-muted-foreground transition-colors hover:text-foreground\",\n                        i % 3 !== 2 && \"border-r border-border\",\n                        i < 3 && \"border-b border-border\",\n                      )}\n                    >\n                      <motion.span\n                        initial={\n                          reduce\n                            ? { opacity: 0 }\n                            : { opacity: 0, scale: 0.85, filter: \"blur(6px)\" }\n                        }\n                        animate={{ opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n                        transition={{\n                          delay: reduce ? 0 : 0.1 + dist * 0.07,\n                          type: \"spring\",\n                          stiffness: 440,\n                          damping: 34,\n                        }}\n                        className=\"flex flex-col items-center gap-2\"\n                      >\n                        <item.icon className=\"h-5 w-5\" />\n                        <span className=\"text-sm font-medium\">{item.label}</span>\n                      </motion.span>\n                    </button>\n                    );\n                  })}\n                </motion.div>\n              </motion.div>\n            </motion.div>\n          ) : (\n            <motion.button\n              key=\"trigger\"\n              type=\"button\"\n              layoutId={layoutId}\n              transition={morph}\n              style={{ borderRadius: 16 }}\n              onClick={() => setOpen(true)}\n              aria-haspopup=\"menu\"\n              aria-expanded={open}\n              whileTap={reduce ? undefined : { scale: 0.97 }}\n              className=\"inline-flex h-11 w-36 items-center justify-center border border-border bg-card text-sm font-medium text-foreground\"\n            >\n              {/* own `layout` counter-scales the label so it stays crisp while the\n                  button box morphs, instead of stretching with it */}\n              <motion.span\n                layout\n                className=\"inline-flex items-center gap-2 whitespace-nowrap\"\n              >\n                Create\n                <Plus className=\"h-4 w-4\" />\n              </motion.span>\n            </motion.button>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"},{"path":"lib/ease.ts","type":"registry:lib","target":"@lib/ease.ts","content":"// Shared motion tokens. Easing curves mirror the CSS custom properties in\n// globals.css; springs are the canonical physics used across components.\n// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.\n\nexport const EASE_OUT = [0.16, 1, 0.3, 1] as const;\nexport const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;\nexport const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;\n\n/** CSS string form of EASE_OUT for inline style transitions. */\nexport const EASE_OUT_CSS = \"cubic-bezier(0.16, 1, 0.3, 1)\";\n\n/** Press feedback on buttons and other tappable surfaces. */\nexport const SPRING_PRESS = {\n  type: \"spring\",\n  stiffness: 500,\n  damping: 30,\n  mass: 0.6,\n} as const;\n\n/** Content swaps — label/icon slots trading places inside a control. */\nexport const SPRING_SWAP = {\n  type: \"spring\",\n  stiffness: 460,\n  damping: 30,\n  mass: 0.55,\n} as const;\n\n/** Overlay panel entrances — modals and sheets summoned by pointer. */\nexport const SPRING_PANEL = {\n  type: \"spring\",\n  stiffness: 420,\n  damping: 40,\n  mass: 0.5,\n} as const;\n\n/** Shared-layout glides — pills, indicators and panels morphing between positions. */\nexport const SPRING_LAYOUT = {\n  type: \"spring\",\n  stiffness: 360,\n  damping: 32,\n  mass: 0.6,\n} as const;\n\n/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */\nexport const SPRING_MOUSE = {\n  stiffness: 200,\n  damping: 15,\n  mass: 0.3,\n} as const;\n"},{"path":"lib/utils.ts","type":"registry:lib","target":"@lib/utils.ts","content":"import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs))\n}\n"}]}