{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"create-menu","type":"registry:block","title":"Create Menu","description":"A button that morphs open into a grid menu via shared layout and clip-path, with a bouncy folder-style expand and staggered items.","author":"Saurabh <saurabh10102@gmail.com>","dependencies":["clsx","lucide-react","motion","tailwind-merge"],"registryDependencies":[],"files":[{"path":"components/motion/create-menu.tsx","type":"registry:component","target":"@components/motion/create-menu.tsx","content":"\"use client\";\n\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  Calendar,\n  Files,\n  Flag,\n  FolderClosed,\n  NotebookPen,\n  Plus,\n  Trophy,\n  X,\n} from \"lucide-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: \"Project\", icon: FolderClosed },\n  { label: \"Notebook\", icon: NotebookPen },\n  { label: \"Notes\", icon: Files },\n  { label: \"Goal\", icon: Trophy },\n  { label: \"Milestone\", icon: Flag },\n  { label: \"Event\", icon: Calendar },\n];\n\n// Bouncy folder-open feel: low damping so the panel overshoots as it expands.\nconst SPRING_FOLDER = {\n  type: \"spring\",\n  stiffness: 320,\n  damping: 24,\n  mass: 0.9,\n} as const;\n\nexport interface CreateMenuProps {\n  items?: MenuItem[];\n  onSelect?: (label: string) => void;\n  className?: string;\n}\n\nexport function CreateMenu({\n  items = ITEMS,\n  onSelect,\n  className,\n}: CreateMenuProps) {\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-12 w-44\" 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-[360px] w-[min(86vw,520px)] -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: 18 }}\n              className=\"w-[min(86vw,520px)] 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-5 py-4\">\n                  <span className=\"text-sm font-medium text-muted-foreground\">\n                    Create new\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-5 w-5\" />\n                  </button>\n                </div>\n\n                {/* grid */}\n                <motion.div\n                  initial={reduce ? false : { clipPath: \"inset(0 0 100% 0)\" }}\n                  animate={{ clipPath: \"inset(0 0 0% 0)\" }}\n                  transition={{\n                    delay: reduce ? 0 : 0.1,\n                    duration: 0.4,\n                    ease: EASE_OUT,\n                  }}\n                  className=\"grid grid-cols-3\"\n                >\n                  {items.map((item, i) => (\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-4 py-8 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.14 + i * 0.04,\n                          type: \"spring\",\n                          stiffness: 460,\n                          damping: 30,\n                        }}\n                        className=\"flex flex-col items-center gap-3\"\n                      >\n                        <item.icon className=\"h-6 w-6\" />\n                        <span className=\"text-sm font-medium\">{item.label}</span>\n                      </motion.span>\n                    </button>\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: 18 }}\n              onClick={() => setOpen(true)}\n              aria-haspopup=\"menu\"\n              aria-expanded={open}\n              whileTap={reduce ? undefined : { scale: 0.97 }}\n              className=\"inline-flex h-12 w-44 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 new\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"}]}