{
  "name": "notch",
  "type": "registry:ui",
  "dependencies": [
    "motion"
  ],
  "files": [
    {
      "path": "components/ui/notch.tsx",
      "content": "\"use client\";\n\nimport React, { useEffect, useId, useRef, useState } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\nexport type NotchOption = {\n  /** Stable identifier passed back in callbacks. */\n  id: string;\n  /** What renders for this option. Can be text or any node. */\n  label: React.ReactNode;\n  /** Optional leading node (icon, swatch, etc.) shown before the label. */\n  icon?: React.ReactNode;\n};\n\nexport type NotchItem = {\n  /** Stable identifier for the group. */\n  id: string;\n  /** Trigger label shown in the bar. */\n  label: React.ReactNode;\n  /** Optional leading icon for the trigger. */\n  icon?: React.ReactNode;\n  /** The choices revealed when the group is opened. */\n  options: NotchOption[];\n  /** Uncontrolled initial selected option id. */\n  defaultValue?: string;\n  /** Controlled selected option id. */\n  value?: string;\n  /** Show the selected value next to the trigger label. Overrides `showSelectedValue`. */\n  showValue?: boolean;\n  /** Fires with the selected option whenever it changes. */\n  onChange?: (optionId: string, option: NotchOption) => void;\n};\n\nexport interface NotchProps {\n  /** The groups shown inside the notch. Pass one or many. */\n  items: NotchItem[];\n  /** Pin the notch to the top or bottom of the viewport. */\n  position?: \"top\" | \"bottom\";\n  /** Horizontal alignment of the floating notch. */\n  align?: \"start\" | \"center\" | \"end\";\n  /** Fired for any group change, in addition to the per-item callback. */\n  onItemChange?: (\n    itemId: string,\n    optionId: string,\n    option: NotchOption,\n  ) => void;\n  /** Close the panel after selecting an option. */\n  closeOnSelect?: boolean;\n  /** Show each group's selected value next to its trigger label. */\n  showSelectedValue?: boolean;\n  /** Render dotted dividers between groups. */\n  showDividers?: boolean;\n  /** Highlight color for the selected option. Any CSS color or variable. */\n  accentColor?: string;\n  /** Distance from the pinned edge, in pixels. */\n  offset?: number;\n  /** Play the entrance animation on mount. */\n  reveal?: boolean;\n  /** Classes applied to the floating shell. */\n  className?: string;\n  /** Classes applied to every trigger. */\n  itemClassName?: string;\n  /** Classes applied to the options panel. */\n  panelClassName?: string;\n}\n\nconst SHELL_SPRING = { type: \"spring\" as const, stiffness: 380, damping: 34 };\n\nconst LIST_VARIANTS = {\n  hidden: {},\n  visible: { transition: { staggerChildren: 0.045, delayChildren: 0.08 } },\n};\n\nconst OPTION_VARIANTS = {\n  hidden: { opacity: 0, y: -10, filter: \"blur(4px)\" },\n  visible: {\n    opacity: 1,\n    y: 0,\n    filter: \"blur(0px)\",\n    transition: { type: \"spring\" as const, stiffness: 420, damping: 30 },\n  },\n};\n\nfunction NotchDivider() {\n  return (\n    <span\n      aria-hidden\n      className=\"mx-0.5 h-5 w-px shrink-0 self-center\"\n      style={{\n        backgroundImage:\n          \"repeating-linear-gradient(180deg, rgba(255,255,255,0.35) 0px, rgba(255,255,255,0.35) 1px, transparent 1px, transparent 5px)\",\n        backgroundSize: \"1px 4px\",\n        backgroundRepeat: \"repeat-y\",\n      }}\n    />\n  );\n}\n\nexport const Notch = ({\n  items,\n  position = \"bottom\",\n  align = \"center\",\n  onItemChange,\n  closeOnSelect = true,\n  showSelectedValue = true,\n  showDividers = true,\n  accentColor = \"var(--color-blue-500, #3b82f6)\",\n  offset = 16,\n  reveal = true,\n  className,\n  itemClassName,\n  panelClassName,\n}: NotchProps) => {\n  const shellRef = useRef<HTMLDivElement>(null);\n  const shellLayoutId = useId();\n  const [openItemId, setOpenItemId] = useState<string | null>(null);\n  const [internalSelected, setInternalSelected] = useState<\n    Record<string, string>\n  >(() => {\n    const map: Record<string, string> = {};\n    for (const item of items) {\n      if (item.value === undefined) {\n        map[item.id] = item.defaultValue ?? item.options[0]?.id ?? \"\";\n      }\n    }\n    return map;\n  });\n\n  useEffect(() => {\n    function onKey(e: KeyboardEvent) {\n      if (e.key === \"Escape\") setOpenItemId(null);\n    }\n    window.addEventListener(\"keydown\", onKey);\n    return () => window.removeEventListener(\"keydown\", onKey);\n  }, []);\n\n  useEffect(() => {\n    if (!openItemId) return;\n    function onPointerDown(e: PointerEvent) {\n      if (!shellRef.current?.contains(e.target as Node)) setOpenItemId(null);\n    }\n    document.addEventListener(\"pointerdown\", onPointerDown);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown);\n  }, [openItemId]);\n\n  const getSelectedId = (item: NotchItem) =>\n    item.value ?? internalSelected[item.id] ?? item.options[0]?.id;\n\n  const getSelectedOption = (item: NotchItem) =>\n    item.options.find((o) => o.id === getSelectedId(item));\n\n  const handleSelect = (item: NotchItem, option: NotchOption) => {\n    if (item.value === undefined) {\n      setInternalSelected((prev) => ({ ...prev, [item.id]: option.id }));\n    }\n    item.onChange?.(option.id, option);\n    onItemChange?.(item.id, option.id, option);\n    if (closeOnSelect) setOpenItemId(null);\n  };\n\n  const alignClass =\n    align === \"start\"\n      ? \"justify-start\"\n      : align === \"end\"\n        ? \"justify-end\"\n        : \"justify-center\";\n\n  const edgeOffset = (offset + 20) * (position === \"top\" ? -1 : 1);\n  const openItem = items.find((i) => i.id === openItemId) ?? null;\n\n  const optionsPanel = openItem ? (\n    <motion.div\n      key={openItem.id}\n      role=\"listbox\"\n      aria-label={\n        typeof openItem.label === \"string\" ? openItem.label : openItem.id\n      }\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0 }}\n      transition={{ duration: 0.15 }}\n      className={cn(\"w-fit\", panelClassName)}\n    >\n      <motion.div\n        className=\"flex flex-col gap-1.5 p-2\"\n        variants={LIST_VARIANTS}\n        initial=\"hidden\"\n        animate=\"visible\"\n      >\n        {openItem.options.map((option) => {\n          const active = option.id === getSelectedId(openItem);\n          return (\n            <motion.button\n              key={option.id}\n              role=\"option\"\n              aria-selected={active}\n              type=\"button\"\n              variants={OPTION_VARIANTS}\n              onClick={() => handleSelect(openItem, option)}\n              className={cn(\n                \"flex w-full items-center justify-between gap-6 rounded-md px-3 py-2 text-left text-xs font-medium whitespace-nowrap transition-colors\",\n                active\n                  ? \"text-white\"\n                  : \"text-neutral-300 hover:bg-white/5 hover:text-white\",\n              )}\n              style={\n                active\n                  ? {\n                      background: `color-mix(in oklab, ${accentColor} 85%, transparent)`,\n                      boxShadow: `inset 0 0 0 1px color-mix(in oklab, ${accentColor} 40%, transparent)`,\n                    }\n                  : undefined\n              }\n            >\n              <span className=\"flex items-center gap-2.5\">\n                {option.icon ? (\n                  <span className=\"flex shrink-0 items-center justify-center\">\n                    {option.icon}\n                  </span>\n                ) : null}\n                <span>{option.label}</span>\n              </span>\n              {active ? (\n                <span\n                  className=\"size-1.5 shrink-0 rounded-full\"\n                  style={{ background: accentColor }}\n                />\n              ) : null}\n            </motion.button>\n          );\n        })}\n      </motion.div>\n    </motion.div>\n  ) : (\n    <motion.div\n      key=\"__notch-triggers\"\n      initial={{ opacity: 0 }}\n      animate={{ opacity: 1 }}\n      exit={{ opacity: 0 }}\n      transition={{ duration: 0.15 }}\n      className=\"flex w-fit items-center gap-1 p-1\"\n    >\n      {items.map((item, index) => {\n        const selected = getSelectedOption(item);\n        const isLast = index === items.length - 1;\n\n        return (\n          <React.Fragment key={item.id}>\n            <button\n              type=\"button\"\n              aria-haspopup=\"listbox\"\n              aria-expanded={false}\n              onClick={() => setOpenItemId(item.id)}\n              className={cn(\n                \"group flex items-center gap-2 rounded-full px-3 py-2 text-xs font-medium whitespace-nowrap text-neutral-300 transition-colors hover:bg-white/6 hover:text-white\",\n                itemClassName,\n              )}\n            >\n              {item.icon ? (\n                <span className=\"flex shrink-0 items-center justify-center\">\n                  {item.icon}\n                </span>\n              ) : null}\n              <span className=\"text-neutral-100\">{item.label}</span>\n              {(item.showValue ?? showSelectedValue) && selected ? (\n                <span className=\"text-neutral-400\">{selected.label}</span>\n              ) : null}\n            </button>\n            {showDividers && !isLast ? <NotchDivider /> : null}\n          </React.Fragment>\n        );\n      })}\n    </motion.div>\n  );\n\n  return (\n    <div\n      className={cn(\n        \"pointer-events-none fixed inset-x-0 z-100 flex translate-z-0 px-4\",\n        position === \"top\" ? \"top-0\" : \"bottom-0\",\n        alignClass,\n      )}\n      style={\n        position === \"top\"\n          ? { paddingTop: `max(${offset}px, env(safe-area-inset-top))` }\n          : { paddingBottom: `max(${offset}px, env(safe-area-inset-bottom))` }\n      }\n    >\n      <motion.div\n        ref={shellRef}\n        layoutId={shellLayoutId}\n        layout\n        initial={\n          reveal ? { opacity: 0, y: edgeOffset, filter: \"blur(6px)\" } : false\n        }\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        transition={SHELL_SPRING}\n        className={cn(\n          \"pointer-events-auto flex w-fit flex-col overflow-hidden rounded-xl border border-white/10 bg-neutral-950/95 shadow-[0_12px_40px_-8px_rgba(0,0,0,0.55)] ring-1 ring-neutral-800 backdrop-blur-2xl ring-inset\",\n          className,\n        )}\n      >\n        <AnimatePresence mode=\"popLayout\" initial={false}>\n          {optionsPanel}\n        </AnimatePresence>\n      </motion.div>\n    </div>\n  );\n};\n",
      "type": "registry:ui",
      "target": "components/ui/notch.tsx"
    }
  ],
  "author": "Manu Arora <hi@manuarora.in>",
  "title": "Notch"
}