{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"sidebar-submenu","type":"registry:component","title":"Sidebar sub-menu","description":"Opens one section at a time and lights the branch from the title down to the item you are on.","author":"Yash Bavadiya <https://xevrion.dev>","dependencies":["motion"],"registryDependencies":["utils"],"files":[{"path":"components/sidebar-submenu.tsx","type":"registry:component","content":"\"use client\";\n\nimport { useEffect, useState } from \"react\";\nimport {\n  AnimatePresence,\n  animate,\n  motion,\n  useMotionValue,\n  useTransform,\n} from \"motion/react\";\nimport { usePreviewPlay } from \"@/lib/preview-play\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\nimport { cn } from \"@/lib/utils\";\n\n// After Pranav Patel's sidebar sub-menu (https://x.com/thatspranav): one\n// section open at a time, its items hanging off a tree, and the branch to\n// the item you're on drawn in the section's colour.\n\nexport type SubmenuSection = {\n  title: string;\n  // The section's hue. Any CSS colour, light-dark() included.\n  tone: string;\n  items: { label: string; icon: React.ReactNode }[];\n};\n\ntype Spot = { section: number; item: number };\n\nconst EASE_OUT = [0.23, 1, 0.32, 1] as const;\n// Opening and closing share one curve, so while one section folds as\n// another unfolds the menu's total height holds still and nothing below\n// it bobs.\nconst FOLD = { duration: 0.32, ease: EASE_OUT };\nconst OPEN = FOLD;\nconst CLOSE = FOLD;\nconst SLIDE = { type: \"spring\", visualDuration: 0.35, bounce: 0.12 } as const;\n// A section title's height (h-11), which the bar steps by.\nconst TITLE = 44;\n\n// Tree geometry, in px. Rows are a fixed height so the branches can be\n// drawn as one path rather than measured.\nconst ROW = 38;\nconst TRUNK = 8;\n// Where a branch's curve starts above the row's middle, and how far it\n// reaches toward the icon.\nconst BEND = 7;\nconst REACH = 22;\n\n// Held outside so the default never reads as a new value each render.\nconst FIRST: Spot = { section: 0, item: 0 };\n\nconst rowMiddle = (i: number) => i * ROW + ROW / 2;\nconst branch = (y: number) =>\n  `M${TRUNK} ${y - BEND} Q${TRUNK} ${y} ${TRUNK + BEND} ${y} H${REACH}`;\n\nexport function SidebarSubmenu({\n  sections,\n  defaultActive = FIRST,\n  onSelect,\n  className,\n}: {\n  sections: SubmenuSection[];\n  defaultActive?: Spot;\n  onSelect?: (section: string, item: string) => void;\n  className?: string;\n}) {\n  const reduceMotion = useReducedMotion();\n  const play = usePreviewPlay();\n  const [own, setOwn] = useState<Spot>(defaultActive);\n  // The index card clicks through on its own state.\n  const [show, setShow] = useState<Spot>(defaultActive);\n  const active = play ? show : own;\n\n  const pick = (spot: Spot) => {\n    setOwn(spot);\n    const s = sections[spot.section];\n    onSelect?.(s.title, s.items[spot.item].label);\n  };\n\n  // The card's show: someone working down the menu, one item a beat, then\n  // on to the next section.\n  useEffect(() => {\n    if (!play) return;\n    const timer = setInterval(() => {\n      setShow(({ section, item }) =>\n        item + 1 < sections[section].items.length\n          ? { section, item: item + 1 }\n          : { section: (section + 1) % sections.length, item: 0 },\n      );\n    }, 1000);\n    return () => {\n      clearInterval(timer);\n      setShow(defaultActive);\n    };\n  }, [play, sections, defaultActive]);\n\n  return (\n    <nav\n      aria-label=\"Workspace\"\n      className={cn(\n        \"w-[340px] max-w-full rounded-[28px] bg-background py-6 pr-6 pl-7 shadow-raised\",\n        className,\n      )}\n    >\n      {/* The rail the section bar rides on. */}\n      <div className=\"relative pl-5\">\n        <span\n          aria-hidden\n          className=\"absolute inset-y-0 left-0 w-px bg-border\"\n        />\n        {/* With one section open, its title always settles at its index\n              times the title height, so the bar slides straight there while\n              the sections fold around it. Centred on the 1px rail. */}\n        <motion.span\n          aria-hidden\n          className=\"absolute top-2.5 -left-px h-6 w-[3px] rounded-full\"\n          initial={false}\n          animate={{ y: active.section * TITLE }}\n          transition={reduceMotion ? { duration: 0 } : SLIDE}\n          style={{ backgroundColor: sections[active.section].tone }}\n        />\n        {sections.map((section, s) => {\n          const open = s === active.section;\n          return (\n            <div\n              key={section.title}\n              style={{ \"--tone\": section.tone } as React.CSSProperties}\n            >\n              <button\n                type=\"button\"\n                aria-expanded={open}\n                onClick={() => !open && pick({ section: s, item: 0 })}\n                className={cn(\n                  \"relative flex h-11 w-full items-center rounded-md text-left text-[17px] outline-hidden transition-[color] duration-200 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-2 focus-visible:outline-foreground\",\n                  open\n                    ? \"font-medium text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                {section.title}\n              </button>\n              <AnimatePresence initial={false}>\n                {open && (\n                  <motion.div\n                    key=\"items\"\n                    className=\"overflow-hidden\"\n                    initial={\n                      reduceMotion ? { opacity: 0 } : { height: 0, opacity: 0 }\n                    }\n                    animate={{ height: \"auto\", opacity: 1, transition: OPEN }}\n                    exit={\n                      reduceMotion\n                        ? { opacity: 0, transition: { duration: 0.1 } }\n                        : { height: 0, opacity: 0, transition: CLOSE }\n                    }\n                  >\n                    <Branches\n                      items={section.items}\n                      active={active.item}\n                      reduceMotion={reduceMotion}\n                      onPick={(item) => pick({ section: s, item })}\n                    />\n                  </motion.div>\n                )}\n              </AnimatePresence>\n            </div>\n          );\n        })}\n      </div>\n    </nav>\n  );\n}\n\nfunction Branches({\n  items,\n  active,\n  reduceMotion,\n  onPick,\n}: {\n  items: SubmenuSection[\"items\"];\n  active: number;\n  reduceMotion: boolean;\n  onPick: (item: number) => void;\n}) {\n  const last = items.length - 1;\n  // The grey tree: a trunk down to the last item, a curved branch to each.\n  const tree = [\n    `M${TRUNK} 0 V${rowMiddle(last) - BEND}`,\n    ...items.map((_, i) => branch(rowMiddle(i))),\n  ].join(\" \");\n\n  // The coloured branch runs from the top of the trunk to the active item,\n  // and follows it, so moving down the list stretches it rather than\n  // redrawing it.\n  const y = useMotionValue(rowMiddle(active));\n  const lit = useTransform(\n    y,\n    (v) =>\n      `M${TRUNK} 0 V${v - BEND} Q${TRUNK} ${v} ${TRUNK + BEND} ${v} H${REACH}`,\n  );\n  useEffect(() => {\n    const target = rowMiddle(active);\n    if (reduceMotion) y.jump(target);\n    else animate(y, target, SLIDE);\n  }, [active, reduceMotion, y]);\n\n  return (\n    <div className=\"relative pb-2\">\n      <svg\n        aria-hidden\n        className=\"pointer-events-none absolute top-0 left-0 overflow-visible\"\n        width={REACH}\n        height={items.length * ROW}\n        fill=\"none\"\n        strokeWidth={1.25}\n        strokeLinecap=\"round\"\n      >\n        <path d={tree} className=\"stroke-border\" />\n        <motion.path\n          d={lit}\n          className=\"stroke-(--tone)\"\n          // Drawn down from the section title as the section opens.\n          initial={reduceMotion ? false : { pathLength: 0 }}\n          animate={{ pathLength: 1 }}\n          transition={{ duration: 0.4, ease: EASE_OUT, delay: 0.08 }}\n        />\n      </svg>\n      <ul>\n        {items.map(({ label, icon }, i) => {\n          const on = i === active;\n          return (\n            <motion.li\n              key={label}\n              // Items settle in one after another as the section opens.\n              initial={\n                reduceMotion\n                  ? false\n                  : { opacity: 0, y: -4, filter: \"blur(4px)\" }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              transition={{\n                duration: 0.28,\n                ease: EASE_OUT,\n                delay: 0.05 + i * 0.04,\n              }}\n            >\n              <button\n                type=\"button\"\n                aria-current={on ? \"true\" : undefined}\n                onClick={() => onPick(i)}\n                className={cn(\n                  \"group/item flex h-[38px] w-full items-center gap-2.5 rounded-md pl-[30px] text-left text-[15px] outline-hidden transition-[color] duration-150 ease-out focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-offset-1 focus-visible:outline-foreground\",\n                  on\n                    ? \"font-medium text-foreground\"\n                    : \"text-muted-foreground hover:text-foreground\",\n                )}\n              >\n                <span\n                  className={cn(\n                    \"size-[17px] shrink-0 transition-[color] duration-200 ease-out\",\n                    on ? \"text-(--tone)\" : \"text-muted-foreground\",\n                  )}\n                >\n                  {icon}\n                </span>\n                <span className=\"truncate\">{label}</span>\n              </button>\n            </motion.li>\n          );\n        })}\n      </ul>\n    </div>\n  );\n}\n\nconst ICON = {\n  viewBox: \"0 0 16 16\",\n  className: \"size-[17px]\",\n  fill: \"none\",\n  stroke: \"currentColor\",\n  strokeWidth: 1.4,\n  strokeLinecap: \"round\",\n  strokeLinejoin: \"round\",\n  \"aria-hidden\": true,\n} as const;\n\n// Section hues are data: each keeps its own, a step lighter in dark mode so\n// it holds the same contrast.\nconst SECTIONS: SubmenuSection[] = [\n  {\n    title: \"Inbox\",\n    tone: \"light-dark(oklch(0.64 0.18 45), oklch(0.74 0.15 50))\",\n    items: [\n      {\n        label: \"Unread\",\n        icon: (\n          <svg {...ICON}>\n            <rect x=\"2\" y=\"3.5\" width=\"12\" height=\"9\" rx=\"1.75\" />\n            <path d=\"m2.5 4.5 5.5 4 5.5-4\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"Mentions\",\n        icon: (\n          <svg {...ICON}>\n            <circle cx=\"8\" cy=\"8\" r=\"2.5\" />\n            <path d=\"M10.5 8v1a1.75 1.75 0 0 0 3.5 0V8a6 6 0 1 0-2.5 4.9\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"Archived\",\n        icon: (\n          <svg {...ICON}>\n            <rect x=\"2\" y=\"2.75\" width=\"12\" height=\"3.25\" rx=\"1\" />\n            <path d=\"M3 6v6.25c0 .55.45 1 1 1h8c.55 0 1-.45 1-1V6M6.5 8.75h3\" />\n          </svg>\n        ),\n      },\n    ],\n  },\n  {\n    title: \"Analytics\",\n    tone: \"light-dark(oklch(0.55 0.18 260), oklch(0.72 0.14 255))\",\n    items: [\n      {\n        label: \"Overview\",\n        icon: (\n          <svg {...ICON}>\n            <path d=\"m2 11 4-4 3 3 5-5.5\" />\n            <path d=\"M10.5 4.5H14V8\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"Audience\",\n        icon: (\n          <svg {...ICON}>\n            <circle cx=\"6\" cy=\"5.5\" r=\"2.25\" />\n            <path d=\"M2 13c.4-2.2 2-3.5 4-3.5s3.6 1.3 4 3.5\" />\n            <path d=\"M10.5 3.5a2.25 2.25 0 0 1 0 4.25M12 9.75c1.1.5 1.85 1.6 2 3.25\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"Reports\",\n        icon: (\n          <svg {...ICON}>\n            <path d=\"M4 1.75h5.25L12.5 5v8.25c0 .55-.45 1-1 1H4c-.55 0-1-.45-1-1V2.75c0-.55.45-1 1-1Z\" />\n            <path d=\"M6 9.5v2M8.25 7.5v4M10.5 10v1.5\" />\n          </svg>\n        ),\n      },\n    ],\n  },\n  {\n    title: \"Automations\",\n    tone: \"light-dark(oklch(0.55 0.2 300), oklch(0.72 0.15 300))\",\n    items: [\n      {\n        label: \"Workflows\",\n        icon: (\n          <svg {...ICON}>\n            <circle cx=\"4\" cy=\"4\" r=\"1.75\" />\n            <circle cx=\"4\" cy=\"12\" r=\"1.75\" />\n            <circle cx=\"12\" cy=\"8\" r=\"1.75\" />\n            <path d=\"M4 5.75v4.5M5.75 4H8a2 2 0 0 1 2 2v.25\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"Triggers\",\n        icon: (\n          <svg {...ICON}>\n            <path d=\"M9 1.75 3.5 9H8l-1 5.25L12.5 7H8l1-5.25Z\" />\n          </svg>\n        ),\n      },\n      {\n        label: \"History\",\n        icon: (\n          <svg {...ICON}>\n            <circle cx=\"8\" cy=\"8\" r=\"6\" />\n            <path d=\"M8 4.75V8l2.25 1.5\" />\n          </svg>\n        ),\n      },\n    ],\n  },\n];\n\nconst MENTIONS: Spot = { section: 0, item: 1 };\n\nexport default function SidebarSubmenuDemo() {\n  return <SidebarSubmenu sections={SECTIONS} defaultActive={MENTIONS} />;\n}\n"},{"path":"lib/preview-play.ts","type":"registry:lib","target":"lib/preview-play.ts","content":"\"use client\";\n\nimport { createContext, useContext } from \"react\";\n\n// Lets a demo know whether it's showing in an index card, and whether that\n// card is hovered or focused. Demos use it to act themselves out on hover:\n// a star button stars itself, a checkbox scribbles, and so on.\n//\n// null: not in a preview (the component's own page, or real use).\n// false: in a preview, at rest.\n// true: in a preview, being hovered or focused: play the show.\nexport const PreviewPlayContext = createContext<boolean | null>(null);\n\nexport function usePreviewPlay() {\n  return useContext(PreviewPlayContext);\n}\n"},{"path":"hooks/use-reduced-motion.ts","type":"registry:hook","target":"hooks/use-reduced-motion.ts","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n// Motion's useReducedMotion reads the media query on the very first client\n// render, but the server can't know it, so every component that renders\n// differently under reduced motion broke hydration for those users. This\n// reports false during hydration, matching the server HTML, then the real\n// preference straight after, and follows it if it changes.\nconst query = \"(prefers-reduced-motion: reduce)\";\n\nfunction subscribe(onChange: () => void) {\n  const media = window.matchMedia(query);\n  media.addEventListener(\"change\", onChange);\n  return () => media.removeEventListener(\"change\", onChange);\n}\n\nexport function useReducedMotion() {\n  return useSyncExternalStore(\n    subscribe,\n    () => window.matchMedia(query).matches,\n    () => false,\n  );\n}\n"}],"cssVars":{"light":{"shadow-raised":"0 0 0 1px oklch(0 0 0 / 0.06), 0 1px 2px oklch(0 0 0 / 0.06), 0 6px 16px -6px oklch(0 0 0 / 0.12)"},"dark":{"shadow-raised":"0 0 0 1px oklch(1 0 0 / 0.08), 0 1px 2px oklch(0 0 0 / 0.4), 0 6px 16px -6px oklch(0 0 0 / 0.6)"},"theme":{"shadow-raised":"var(--shadow-raised)"}},"categories":["navigation"],"docs":"From ui lab: https://lab.xevrion.dev/lab/sidebar-submenu"}