{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "blur-glide-menu",
  "title": "Blur Glide Menu",
  "author": "henriquegpb <https://github.com/henriquegpb>",
  "description": "A nested menu whose steps blur before they glide aside, while the surface animates to the height of the step arriving.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/microkit/blur-glide-menu.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport type { KeyboardEvent as ReactKeyboardEvent, RefObject } from \"react\";\nimport { Archive, ArrowLeft, ChevronDown, ChevronRight, Copy, FolderInput, Globe, Inbox, Layers, Palette, Tag, UserPlus } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\n\ntype MenuRow = {\n  id: string;\n  label: string;\n  icon?: LucideIcon;\n  initials?: string;\n  tint?: string;\n  dot?: string;\n  hint?: string;\n  panel?: MenuPanel;\n};\n\ntype MenuPanel = { search?: string; rows: MenuRow[] };\n\nconst rootPanel: MenuPanel = {\n  rows: [\n    {\n      id: \"invite\",\n      label: \"Invite people\",\n      icon: UserPlus,\n      hint: \"@\",\n      panel: {\n        search: \"Find people\",\n        rows: [\n          { id: \"priya\", label: \"Priya Raman\", initials: \"PR\", tint: \"#a8c7ff\" },\n          { id: \"noah\", label: \"Noah Whitfield\", initials: \"NW\", tint: \"#ffd6a5\" },\n          { id: \"ines\", label: \"Ines Duarte\", initials: \"ID\", tint: \"#b9f0d0\" },\n        ],\n      },\n    },\n    {\n      id: \"move\",\n      label: \"Move to\",\n      icon: FolderInput,\n      hint: \"M\",\n      panel: {\n        search: \"Find a space\",\n        rows: [\n          {\n            id: \"roadmap\",\n            label: \"Roadmap\",\n            icon: Layers,\n            panel: {\n              search: \"Find a board\",\n              rows: [\n                { id: \"quarter\", label: \"Quarter planning\", initials: \"QP\", tint: \"#a8c7ff\" },\n                { id: \"bets\", label: \"Platform bets\", initials: \"PB\", tint: \"#d9c2ff\" },\n                { id: \"hiring\", label: \"Hiring plan\", initials: \"HP\", tint: \"#ffd6a5\" },\n                { id: \"launch\", label: \"Launch checklist\", initials: \"LC\", tint: \"#b9f0d0\" },\n              ],\n            },\n          },\n          {\n            id: \"system\",\n            label: \"Design system\",\n            icon: Palette,\n            panel: {\n              search: \"Find a board\",\n              rows: [\n                { id: \"tokens\", label: \"Colour tokens\", initials: \"CT\", tint: \"#ffc9d4\" },\n                { id: \"specs\", label: \"Component specs\", initials: \"CS\", tint: \"#a8c7ff\" },\n                { id: \"icons\", label: \"Icon set\", initials: \"IS\", tint: \"#b9f0d0\" },\n              ],\n            },\n          },\n          { id: \"site\", label: \"Marketing site\", icon: Globe },\n          { id: \"support\", label: \"Support inbox\", icon: Inbox },\n        ],\n      },\n    },\n    {\n      id: \"label\",\n      label: \"Set label\",\n      icon: Tag,\n      hint: \"L\",\n      panel: {\n        rows: [\n          { id: \"priority\", label: \"Priority\", dot: \"#ff9c9c\" },\n          { id: \"blocked\", label: \"Blocked\", dot: \"#ffd6a5\" },\n          { id: \"research\", label: \"Research\", dot: \"#a8c7ff\" },\n          { id: \"shipped\", label: \"Shipped\", dot: \"#b9f0d0\" },\n        ],\n      },\n    },\n    { id: \"duplicate\", label: \"Duplicate\", icon: Copy, hint: \"⌘D\" },\n    { id: \"archive\", label: \"Archive\", icon: Archive },\n  ],\n};\n\nconst ROW_HEIGHT = 26;\nconst PANEL_MS = 420;\nconst CLOSE_MS = 240;\n\nconst panelKeyframes = `\n@keyframes glide-menu-in {\n  0% { opacity: 0; transform: translateX(100%); filter: blur(8px); }\n  45% { opacity: .9; }\n  65% { filter: blur(6.5px); }\n  100% { opacity: 1; transform: translateX(0); filter: blur(0); }\n}\n@keyframes glide-menu-out {\n  0% { opacity: 1; transform: translateX(0); filter: blur(0); }\n  30% { opacity: .9; filter: blur(6px); }\n  100% { opacity: 0; transform: translateX(-100%); filter: blur(9px); }\n}\n@keyframes glide-menu-in-back {\n  0% { opacity: 0; transform: translateX(-100%); filter: blur(8px); }\n  45% { opacity: .9; }\n  65% { filter: blur(6.5px); }\n  100% { opacity: 1; transform: translateX(0); filter: blur(0); }\n}\n@keyframes glide-menu-out-back {\n  0% { opacity: 1; transform: translateX(0); filter: blur(0); }\n  30% { opacity: .9; filter: blur(6px); }\n  100% { opacity: 0; transform: translateX(100%); filter: blur(9px); }\n}`;\n\nconst phaseClass = {\n  idle: \"\",\n  in: \"[animation:glide-menu-in_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]\",\n  \"in-back\": \"[animation:glide-menu-in-back_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:[animation-duration:.01ms]\",\n  out: \"absolute inset-x-0 top-0 [animation:glide-menu-out_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:animate-none motion-reduce:opacity-0\",\n  \"out-back\": \"absolute inset-x-0 top-0 [animation:glide-menu-out-back_.4s_cubic-bezier(.45,.05,.15,1)_both] motion-reduce:animate-none motion-reduce:opacity-0\",\n};\n\nfunction stepAt(trail: string[]) {\n  let panel = rootPanel;\n  let title = \"\";\n  for (const id of trail) {\n    const row = panel.rows.find((candidate) => candidate.id === id);\n    if (!row?.panel) break;\n    panel = row.panel;\n    title = row.label;\n  }\n  return { panel, title };\n}\n\nfunction matches(panel: MenuPanel, query: string) {\n  const term = query.trim().toLowerCase();\n  return term ? panel.rows.filter((row) => row.label.toLowerCase().includes(term)) : panel.rows;\n}\n\nfunction MenuPanel({\n  panel,\n  title,\n  phase,\n  query,\n  panelRef,\n  searchRef,\n  onQuery,\n  onSelect,\n  onBack,\n}: {\n  panel: MenuPanel;\n  title: string;\n  phase: keyof typeof phaseClass;\n  query: string;\n  panelRef?: RefObject<HTMLDivElement | null>;\n  searchRef?: RefObject<HTMLInputElement | null>;\n  onQuery?: (value: string) => void;\n  onSelect?: (row: MenuRow) => void;\n  onBack?: () => void;\n}) {\n  const [cursor, setCursor] = useState({ index: 0, visible: false });\n  const rows = matches(panel, query);\n  const leaving = phase === \"out\" || phase === \"out-back\";\n\n  return (\n    <div className={`w-full ${phaseClass[phase]}`} ref={panelRef} inert={leaving || undefined}>\n      {title && (\n        <>\n          <div className=\"flex h-[26px] items-center gap-[5px] pr-[5px]\">\n            <button\n              type=\"button\"\n              className=\"grid size-5 flex-none place-items-center rounded-[5px] border-0 bg-transparent text-[light-dark(#69727f,#868d97)] [transition:background-color_.18s_ease,color_.18s_ease] hover:bg-[light-dark(#e7eaef,#22262c)] hover:text-[light-dark(#262d38,#dfe2e5)] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-0 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]\"\n              onClick={onBack}\n              aria-label={`Back from ${title}`}\n            >\n              <ArrowLeft size={13} strokeWidth={1.8} aria-hidden=\"true\" />\n            </button>\n            {panel.search ? (\n              <input\n                ref={searchRef}\n                className=\"min-w-0 flex-1 truncate border-0 bg-transparent p-0 text-[11px] text-[light-dark(#262d38,#dfe2e5)] placeholder:text-[light-dark(#868f9c,#6d747e)] focus:outline-none focus-visible:outline-none\"\n                data-search=\"\"\n                type=\"text\"\n                value={query}\n                placeholder={panel.search}\n                aria-label={panel.search}\n                onChange={(event) => onQuery?.(event.target.value)}\n              />\n            ) : (\n              <span className=\"min-w-0 flex-1 truncate text-[11px] text-[light-dark(#262d38,#dfe2e5)]\">{title}</span>\n            )}\n          </div>\n          <div className=\"my-[3px] h-px bg-[light-dark(#e7eaef,#23262c)]\" />\n        </>\n      )}\n      <div\n        className=\"relative\"\n        role=\"menu\"\n        onMouseLeave={() => setCursor((state) => ({ ...state, visible: false }))}\n      >\n        <span\n          className={`pointer-events-none absolute inset-x-0 top-0 h-[26px] rounded-[5px] bg-[light-dark(#e7eaef,#22262c)] [transition:transform_.3s_cubic-bezier(.32,.72,0,1),opacity_.18s_ease] motion-reduce:[transition-duration:.01ms] ${cursor.visible ? \"opacity-100\" : \"opacity-0\"}`}\n          style={{ transform: `translateY(${cursor.index * ROW_HEIGHT}px)` }}\n          aria-hidden=\"true\"\n        />\n        {rows.map((row, index) => (\n          <button\n            key={row.id}\n            type=\"button\"\n            role=\"menuitem\"\n            className=\"group relative z-[1] flex h-[26px] w-full items-center gap-2 rounded-[5px] border-0 bg-transparent px-[7px] text-left text-[11px] text-[light-dark(#3b434f,#c6cbd2)] [transition:color_.18s_ease] hover:text-[light-dark(#14181e,#f4f5f7)] focus-visible:text-[light-dark(#14181e,#f4f5f7)] focus-visible:outline focus-visible:outline-[1.5px] focus-visible:outline-offset-[-1.5px] focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]\"\n            data-row=\"\"\n            aria-haspopup={row.panel ? \"menu\" : undefined}\n            onMouseEnter={() => setCursor({ index, visible: true })}\n            onFocus={() => setCursor({ index, visible: true })}\n            onClick={() => onSelect?.(row)}\n            onKeyDown={(event) => {\n              if (event.key === \"ArrowRight\" && row.panel) {\n                event.preventDefault();\n                onSelect?.(row);\n              }\n            }}\n          >\n            {row.icon && (\n              <span className=\"grid size-[14px] flex-none place-items-center text-[light-dark(#69727f,#868d97)] [transition:color_.18s_ease] group-hover:text-[light-dark(#3b434f,#c6cbd2)] group-focus-visible:text-[light-dark(#3b434f,#c6cbd2)] motion-reduce:[transition-duration:.01ms]\">\n                <row.icon size={14} strokeWidth={1.7} aria-hidden=\"true\" />\n              </span>\n            )}\n            {row.initials && (\n              <span\n                className=\"grid size-[18px] flex-none place-items-center rounded-md text-[9px] font-semibold text-[light-dark(#ffffff,#15171a)]\"\n                style={{ background: row.tint }}\n                aria-hidden=\"true\"\n              >\n                {row.initials}\n              </span>\n            )}\n            {row.dot && (\n              <span\n                className=\"mx-[3px] size-2 flex-none rounded-full\"\n                style={{ background: row.dot }}\n                aria-hidden=\"true\"\n              />\n            )}\n            <span className=\"min-w-0 flex-1 truncate\">{row.label}</span>\n            {row.hint && (\n              <span className=\"flex-none font-mono text-[10px] text-[light-dark(#868f9c,#6b727c)]\">{row.hint}</span>\n            )}\n            {row.panel && (\n              <ChevronRight\n                className=\"flex-none text-[light-dark(#a2aab6,#5c636d)] [transition:transform_.24s_cubic-bezier(.32,.72,0,1),color_.18s_ease] group-hover:text-[light-dark(#69727f,#868d97)] group-hover:[transform:translateX(2px)] group-focus-visible:text-[light-dark(#69727f,#868d97)] group-focus-visible:[transform:translateX(2px)] motion-reduce:[transition-duration:.01ms]\"\n                size={13}\n                strokeWidth={1.8}\n                aria-hidden=\"true\"\n              />\n            )}\n          </button>\n        ))}\n        {!rows.length && (\n          <p className=\"m-0 flex h-[26px] items-center px-[7px] text-[11px] text-[light-dark(#868f9c,#6d747e)]\">No matches</p>\n        )}\n      </div>\n    </div>\n  );\n}\n\nexport function BlurGlideMenu() {\n  const [open, setOpen] = useState(true);\n  const [nav, setNav] = useState<{ trail: string[]; direction: -1 | 0 | 1 }>({ trail: [], direction: 0 });\n  const [query, setQuery] = useState(\"\");\n  const [leaving, setLeaving] = useState<{ trail: string[]; query: string; direction: -1 | 1 } | null>(null);\n  const [height, setHeight] = useState<number>();\n  const rootRef = useRef<HTMLDivElement>(null);\n  const activeRef = useRef<HTMLDivElement>(null);\n  const triggerRef = useRef<HTMLButtonElement>(null);\n  const searchRef = useRef<HTMLInputElement>(null);\n\n  const step = stepAt(nav.trail);\n\n  useEffect(() => {\n    if (activeRef.current) setHeight(activeRef.current.offsetHeight);\n  }, [nav, query, open]);\n\n  useEffect(() => {\n    if (!leaving) return;\n    const timer = setTimeout(() => setLeaving(null), PANEL_MS);\n    return () => clearTimeout(timer);\n  }, [leaving]);\n\n  useEffect(() => {\n    if (open) return;\n    const timer = setTimeout(() => {\n      setNav({ trail: [], direction: 0 });\n      setQuery(\"\");\n      setLeaving(null);\n    }, CLOSE_MS);\n    return () => clearTimeout(timer);\n  }, [open]);\n\n  useEffect(() => {\n    if (!open) return;\n    const region = rootRef.current?.parentElement;\n    if (!region) return;\n    const handlePointerDown = (event: Event) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false);\n    };\n    region.addEventListener(\"pointerdown\", handlePointerDown);\n    return () => region.removeEventListener(\"pointerdown\", handlePointerDown);\n  }, [open]);\n\n  useEffect(() => {\n    if (nav.direction === 0) return;\n    const target = searchRef.current ?? activeRef.current?.querySelector<HTMLButtonElement>(\"[data-row]\");\n    target?.focus({ preventScroll: true });\n  }, [nav]);\n\n  const enter = (id: string) => {\n    setLeaving({ trail: nav.trail, query, direction: 1 });\n    setNav({ trail: [...nav.trail, id], direction: 1 });\n    setQuery(\"\");\n  };\n\n  const back = () => {\n    if (!nav.trail.length) return;\n    setLeaving({ trail: nav.trail, query, direction: -1 });\n    setNav({ trail: nav.trail.slice(0, -1), direction: -1 });\n    setQuery(\"\");\n  };\n\n  const dismiss = () => {\n    setOpen(false);\n    triggerRef.current?.focus({ preventScroll: true });\n  };\n\n  const moveFocus = (delta: number) => {\n    const rows = Array.from(activeRef.current?.querySelectorAll<HTMLButtonElement>(\"[data-row]\") ?? []);\n    if (!rows.length) return;\n    const index = rows.indexOf(document.activeElement as HTMLButtonElement);\n    const next = index < 0 ? (delta > 0 ? 0 : rows.length - 1) : (index + delta + rows.length) % rows.length;\n    rows[next].focus({ preventScroll: true });\n  };\n\n  const handleKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n    const inSearch = (event.target as HTMLElement).dataset.search !== undefined;\n    if (event.key === \"ArrowDown\" || event.key === \"ArrowUp\") {\n      event.preventDefault();\n      if (!open) {\n        setOpen(true);\n        return;\n      }\n      moveFocus(event.key === \"ArrowDown\" ? 1 : -1);\n      return;\n    }\n    if (!open) return;\n    if (event.key === \"Escape\") {\n      event.preventDefault();\n      if (nav.trail.length) back();\n      else dismiss();\n      return;\n    }\n    if (event.key === \"ArrowLeft\" && !inSearch && nav.trail.length) {\n      event.preventDefault();\n      back();\n    }\n  };\n\n  return (\n    <div\n      className=\"relative h-[182px] w-[214px] text-center text-[11px] text-[light-dark(#262d38,#dfe2e5)]\"\n      ref={rootRef}\n      onKeyDown={handleKeyDown}\n    >\n      <style>{panelKeyframes}</style>\n      <button\n        type=\"button\"\n        className=\"inline-flex h-[26px] items-center gap-[7px] rounded-md border border-[light-dark(#dfe3ea,#2f333a)] bg-[light-dark(#ffffff,#16181c)] pl-[10px] pr-2 text-[11px] text-[light-dark(#262d38,#dfe2e5)] [transition:border-color_.2s_ease,background-color_.2s_ease] hover:border-[light-dark(#adb6c3,#3d434c)] hover:bg-[light-dark(#f0f2f5,#1b1e23)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#86b6ff] motion-reduce:[transition-duration:.01ms]\"\n        ref={triggerRef}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        onClick={() => (open ? dismiss() : setOpen(true))}\n      >\n        Board actions\n        <ChevronDown\n          className={`text-[light-dark(#69727f,#868d97)] [transition:transform_.34s_cubic-bezier(.32,.72,0,1)] motion-reduce:[transition-duration:.01ms] ${open ? \"[transform:rotate(180deg)]\" : \"\"}`}\n          size={13}\n          strokeWidth={1.8}\n          aria-hidden=\"true\"\n        />\n      </button>\n      <div\n        className={`absolute left-0 top-[33px] z-[1] w-full origin-top rounded-[9px] border border-[light-dark(#dfe3ea,#2b2f36)] bg-[light-dark(#ffffff,#121418)] p-[5px] text-left shadow-[inset_0_1px_0_rgba(255,255,255,.04),0_16px_34px_rgba(0,0,0,.45)] ${\n          open\n            ? \"visible opacity-100 [filter:blur(0px)] [transform:none] [transition:opacity_.22s_ease,transform_.36s_cubic-bezier(.32,.72,0,1),filter_.28s_ease,visibility_0s]\"\n            : \"invisible opacity-0 blur-[5px] [transform:translateY(-6px)_scale(.97)] [transition:opacity_.18s_ease,transform_.26s_cubic-bezier(.32,.72,0,1),filter_.22s_ease,visibility_0s_linear_.26s]\"\n        } motion-reduce:[transition-duration:.01ms]`}\n      >\n        <div\n          className=\"relative overflow-hidden [transition:height_.4s_cubic-bezier(.45,.05,.15,1)] motion-reduce:[transition-duration:.01ms]\"\n          style={{ height }}\n        >\n          {leaving && (\n            <MenuPanel\n              key={leaving.trail.join(\"/\") || \"root\"}\n              {...stepAt(leaving.trail)}\n              phase={leaving.direction === 1 ? \"out\" : \"out-back\"}\n              query={leaving.query}\n            />\n          )}\n          <MenuPanel\n            key={nav.trail.join(\"/\") || \"root\"}\n            {...step}\n            phase={nav.direction === 0 ? \"idle\" : nav.direction === 1 ? \"in\" : \"in-back\"}\n            query={query}\n            panelRef={activeRef}\n            searchRef={step.panel.search ? searchRef : undefined}\n            onQuery={setQuery}\n            onBack={back}\n            onSelect={(row) => (row.panel ? enter(row.id) : dismiss())}\n          />\n        </div>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/microkit/blur-glide-menu.tsx"
    }
  ],
  "docs": "Live preview and the CSS, JavaScript and Tailwind variants: https://microkit.co/components/blur-glide-menu",
  "categories": [
    "navigation",
    "click"
  ],
  "type": "registry:component"
}