{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "vertical-tabs",
  "type": "registry:ui",
  "title": "Vertical Tabs",
  "description": "Settings-style side list with a gliding highlight that folds into a select.",
  "categories": [
    "navigation"
  ],
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/vertical-tabs.tsx",
      "content": "\"use client\";\nimport { Select } from \"@base-ui/react/select\";\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\";\nimport { createContext, use, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Check, ChevronsUpDown } from \"@/lib/icons\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\ntype Value = string;\ntype Entry = { value: Value; label: React.ReactNode; icon?: React.ReactNode; hint?: React.ReactNode; disabled?: boolean; el: HTMLElement };\n\n/**\n * Every tab registers what it shows, so the narrow layout can offer the same\n * sections as a select without the consumer describing them twice.\n */\nfunction createRegistry() {\n  let entries: Entry[] = [];\n  const listeners = new Set<() => void>();\n  const emit = () => listeners.forEach((l) => l());\n  return {\n    subscribe(l: () => void) {\n      listeners.add(l);\n      return () => void listeners.delete(l);\n    },\n    snapshot: () => entries,\n    set(entry: Entry) {\n      const rest = entries.filter((e) => e.value !== entry.value);\n      // Keep document order, whatever order the tabs mounted in.\n      entries = [...rest, entry].sort((a, b) => (a.el.compareDocumentPosition(b.el) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1));\n      emit();\n    },\n    remove(value: Value) {\n      entries = entries.filter((e) => e.value !== value);\n      emit();\n    },\n  };\n}\ntype Registry = ReturnType<typeof createRegistry>;\nconst empty: Entry[] = [];\n\ntype Collapse = \"sm\" | \"md\";\ntype Ctx = { value: Value | null; setValue: (v: Value) => void; registry: Registry; collapse: Collapse };\nconst VerticalTabsContext = createContext<Ctx | null>(null);\nconst useVT = () => {\n  const ctx = use(VerticalTabsContext);\n  if (!ctx) throw new Error(\"VerticalTabs parts must be used inside <VerticalTabs>\");\n  return ctx;\n};\n\nexport type VerticalTabsProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\" | \"dir\"> & {\n  value?: Value | null;\n  /** The section shown first. Without it no section is active until one is picked. */\n  defaultValue?: Value | null;\n  onValueChange?: (value: Value | null) => void;\n  /** Below this container width the side list becomes a select above the panel. */\n  collapseBelow?: Collapse;\n};\n\nexport function VerticalTabs({ value: valueProp, defaultValue = null, onValueChange, collapseBelow = \"sm\", className, children, onKeyDownCapture, onPointerDownCapture, ...rest }: VerticalTabsProps) {\n  const [value, setValue] = useControllableState<Value | null>({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [registry] = useState(createRegistry);\n\n  return (\n    <VerticalTabsContext value={{ value, setValue, registry, collapse: collapseBelow }}>\n      <BaseTabs.Root\n        value={value}\n        onValueChange={(v) => setValue(v as Value)}\n        orientation=\"vertical\"\n        data-vtabs-root=\"\"\n        data-collapse={collapseBelow}\n        // Arrow keys switch sections on the same frame; only pointer switches animate.\n        onKeyDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"key\";\n          onKeyDownCapture?.(e);\n        }}\n        onPointerDownCapture={(e) => {\n          e.currentTarget.dataset.nav = \"pointer\";\n          onPointerDownCapture?.(e);\n        }}\n        className={cn(\"group/vtabs @container/vtabs min-w-0\", className)}\n        {...rest}\n      >\n        <div\n          className={cn(\n            \"flex flex-col gap-4\",\n            collapseBelow === \"sm\" ? \"@[30rem]/vtabs:flex-row @[30rem]/vtabs:gap-6\" : \"@[40rem]/vtabs:flex-row @[40rem]/vtabs:gap-8\",\n          )}\n        >\n          {children}\n        </div>\n      </BaseTabs.Root>\n    </VerticalTabsContext>\n  );\n}\n\n// The side list and the select swap at the same container width: 480px, or 640px for longer labels.\nconst wide: Record<Collapse, string> = {\n  sm: \"hidden @[30rem]/vtabs:block @[30rem]/vtabs:w-[176px] @[40rem]/vtabs:w-[200px]\",\n  md: \"hidden @[40rem]/vtabs:block @[40rem]/vtabs:w-[200px]\",\n};\nconst narrow: Record<Collapse, string> = { sm: \"@[30rem]/vtabs:hidden\", md: \"@[40rem]/vtabs:hidden\" };\n\nexport type VerticalTabsListProps = Omit<BaseTabs.List.Props, \"className\"> & {\n  className?: string;\n  /** Names the list, and the select that replaces it on narrow widths. */\n  \"aria-label\": string;\n};\n\nexport function VerticalTabsList({ className, children, activateOnFocus = true, \"aria-label\": label, ...rest }: VerticalTabsListProps) {\n  const { value, setValue, registry, collapse } = useVT();\n  const entries = useSyncExternalStore(registry.subscribe, registry.snapshot, () => empty);\n  const listRef = useRef<HTMLDivElement>(null);\n  // One hover wash for the whole list, handed from row to row.\n  const [hover, setHover] = useState<{ top: number; height: number; on: boolean; instant: boolean }>({ top: 0, height: 0, on: false, instant: true });\n\n  const onPointerOver = (e: React.PointerEvent) => {\n    if (e.pointerType !== \"mouse\") return;\n    const tab = (e.target as HTMLElement).closest<HTMLElement>(\"[role=tab]\");\n    if (!tab || !listRef.current?.contains(tab) || tab.hasAttribute(\"data-disabled\")) return;\n    const next = { top: tab.offsetTop, height: tab.offsetHeight };\n    setHover((h) => (h.on && h.top === next.top ? h : { ...next, on: true, instant: !h.on }));\n  };\n\n  return (\n    <>\n      <div className={cn(\"shrink-0\", wide[collapse])}>\n        <BaseTabs.List\n          ref={listRef}\n          aria-label={label}\n          activateOnFocus={activateOnFocus}\n          onPointerOver={onPointerOver}\n          onPointerLeave={() => setHover((h) => ({ ...h, on: false }))}\n          className={cn(\"relative isolate flex flex-col gap-px\", className)}\n          {...rest}\n        >\n          {children}\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 top-0 -z-10 rounded-lg bg-fg/[0.035]\",\n              // Glides between rows; appears in place when the pointer first arrives.\n              hover.instant ? \"transition-opacity\" : \"transition-[opacity,translate,height]\",\n              \"duration-150 ease-out-quart\",\n              !hover.on && \"opacity-0\",\n            )}\n            style={{ translate: `0 ${hover.top}px`, height: hover.height }}\n          />\n          <BaseTabs.Indicator\n            className={cn(\n              \"pointer-events-none absolute inset-x-0 top-0 -z-10 h-(--active-tab-height) translate-y-(--active-tab-top) rounded-lg bg-fg/[0.07]\",\n              \"transition-[translate,height] duration-[240ms] ease-in-out-quart group-data-[nav=key]/vtabs:duration-0\",\n            )}\n          />\n        </BaseTabs.List>\n      </div>\n\n      <div className={narrow[collapse]}>\n        <Select.Root value={value} onValueChange={(v) => v != null && setValue(v as Value)}>\n          <Select.Trigger\n            aria-label={label}\n            className={cn(\n              \"group/trigger relative flex h-9 w-full min-w-0 items-center gap-2 rounded-lg border border-line-2 bg-raised pr-2 pl-2.5 text-left text-[13px] text-fg shadow-[var(--shadow)] select-none\",\n              \"outline-none focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-fg-3\",\n              \"transition-[background-color,border-color,scale] duration-150 ease-out active:scale-[0.985] active:duration-75\",\n              \"hover:border-fg-4 hover:bg-hover data-popup-open:border-fg-4 data-popup-open:bg-hover\",\n              \"pointer-coarse:h-11 pointer-coarse:text-[15px]\",\n            )}\n          >\n            <Select.Value\n              render={(props) => {\n                const current = entries.find((e) => e.value === value);\n                return (\n                  <span {...props} className=\"flex min-w-0 flex-1 items-center gap-2\">\n                    {current?.icon && <span className=\"flex shrink-0 text-fg-3 [&_svg]:size-4\">{current.icon}</span>}\n                    <span className=\"truncate\">{current?.label ?? <span className=\"text-fg-4\">Choose a section</span>}</span>\n                  </span>\n                );\n              }}\n            />\n            <Select.Icon className=\"flex shrink-0 text-fg-3 transition-colors duration-150 group-hover/trigger:text-fg-2 [&_svg]:size-4\">\n              <ChevronsUpDown />\n            </Select.Icon>\n          </Select.Trigger>\n          <Select.Portal>\n            <Select.Positioner alignItemWithTrigger={false} sideOffset={6} collisionPadding={8} className=\"z-(--z-popover) outline-none select-none\">\n              <Select.Popup\n                className={cn(\n                  \"relative min-w-(--anchor-width) max-w-(--available-width) origin-(--transform-origin) overflow-hidden rounded-xl border border-line-2 bg-raised text-fg shadow-pop outline-none\",\n                  \"transition-[opacity,scale] duration-160 ease-out-expo data-ending-style:duration-100 data-ending-style:ease-out\",\n                  \"data-starting-style:scale-[0.96] data-starting-style:opacity-0 data-ending-style:scale-[0.98] data-ending-style:opacity-0\",\n                )}\n              >\n                <Select.List className=\"max-h-[min(var(--available-height),20rem)] scroll-py-1 overflow-y-auto overscroll-contain p-1 outline-none\">\n                  {entries.map((e) => (\n                    <Select.Item\n                      key={e.value}\n                      value={e.value}\n                      disabled={e.disabled}\n                      className={cn(\n                        \"group/item flex h-8 cursor-default scroll-my-1 items-center gap-2 rounded-lg pr-2 pl-2 text-[13px] text-fg outline-none select-none pointer-coarse:h-11\",\n                        \"data-highlighted:bg-fg/[0.07] data-disabled:text-fg-4\",\n                      )}\n                    >\n                      {e.icon && <span className=\"flex shrink-0 text-fg-3 group-data-disabled/item:text-fg-4 [&_svg]:size-4\">{e.icon}</span>}\n                      <Select.ItemText className=\"min-w-0 flex-1 truncate\">{e.label}</Select.ItemText>\n                      {e.hint != null && <span className=\"shrink-0 text-[12px] text-fg-3 tabular group-data-disabled/item:text-fg-4\">{e.hint}</span>}\n                      <span className=\"grid size-4 shrink-0 place-items-center\">\n                        <Select.ItemIndicator>\n                          <Check size={14} />\n                        </Select.ItemIndicator>\n                      </span>\n                    </Select.Item>\n                  ))}\n                </Select.List>\n              </Select.Popup>\n            </Select.Positioner>\n          </Select.Portal>\n        </Select.Root>\n      </div>\n    </>\n  );\n}\n\nexport type VerticalTabsTabProps = Omit<BaseTabs.Tab.Props, \"className\" | \"children\" | \"value\"> & {\n  value: Value;\n  className?: string;\n  children: React.ReactNode;\n  /** 16px icon before the label. */\n  icon?: React.ReactNode;\n  /** Right-aligned meta: a count, a plan, a status word. */\n  hint?: React.ReactNode;\n};\n\nexport function VerticalTabsTab({ value, icon, hint, disabled, className, children, ...rest }: VerticalTabsTabProps) {\n  const { registry } = useVT();\n  const ref = useRef<HTMLButtonElement>(null);\n\n  useLayoutEffect(() => {\n    if (ref.current) registry.set({ value, label: children, icon, hint, disabled, el: ref.current });\n  }, [registry, value, children, icon, hint, disabled]);\n  useLayoutEffect(() => () => registry.remove(value), [registry, value]);\n\n  return (\n    <BaseTabs.Tab\n      ref={ref}\n      value={value}\n      disabled={disabled}\n      className={cn(\n        \"group/tab relative flex h-8 w-full min-w-0 select-none items-center gap-2.5 rounded-lg px-2.5 text-left text-[13px] outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"text-fg-2 transition-colors duration-150 hover:text-fg data-active:text-fg data-active:font-medium\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:-outline-offset-1 focus-visible:outline-fg-3\",\n        \"data-disabled:pointer-events-none data-disabled:text-fg-4\",\n        className,\n      )}\n      {...rest}\n    >\n      {icon && (\n        <span className=\"flex shrink-0 text-fg-3 transition-[color,scale] duration-150 ease-out-quart group-hover/tab:text-fg-2 group-active/tab:scale-[0.9] group-data-active/tab:text-fg [&_svg]:size-4\">\n          {icon}\n        </span>\n      )}\n      {/* The label reserves its bold width, so becoming active never nudges the hint. */}\n      <span className=\"grid min-w-0 flex-1\">\n        <span aria-hidden className=\"invisible col-start-1 row-start-1 truncate font-medium\">\n          {children}\n        </span>\n        <span className=\"col-start-1 row-start-1 truncate\">{children}</span>\n      </span>\n      {hint != null && <span className=\"shrink-0 text-[12px] font-normal text-fg-3 tabular group-data-disabled/tab:text-fg-4\">{hint}</span>}\n    </BaseTabs.Tab>\n  );\n}\n\nexport type VerticalTabsLabelProps = React.ComponentProps<\"div\">;\n\n/** A quiet group heading between tabs (\"Account\", \"Workspace\"). Shown in the side list only. */\nexport function VerticalTabsLabel({ className, ...rest }: VerticalTabsLabelProps) {\n  return (\n    <div\n      aria-hidden\n      className={cn(\"px-2.5 pt-4 pb-1.5 font-mono text-2xs tracking-[0.08em] text-fg-4 uppercase select-none first:pt-1\", className)}\n      {...rest}\n    />\n  );\n}\n\nexport type VerticalTabsPanelsProps = React.ComponentProps<\"div\">;\n\n/** Stacks the panels in one cell so the outgoing one fades while the next arrives. */\nexport function VerticalTabsPanels({ className, ...rest }: VerticalTabsPanelsProps) {\n  return <div className={cn(\"relative grid min-w-0 flex-1 grid-cols-1 content-start\", className)} {...rest} />;\n}\n\nexport type VerticalTabsPanelProps = Omit<BaseTabs.Panel.Props, \"className\"> & { className?: string };\n\nexport function VerticalTabsPanel({ className, ...rest }: VerticalTabsPanelProps) {\n  return (\n    <BaseTabs.Panel\n      className={cn(\n        \"col-start-1 row-start-1 min-w-0 rounded-md outline-none\",\n        \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-[6px] focus-visible:outline-fg-3\",\n        // Arrives from the direction of the section picked; leaves by fading out of the flow.\n        \"transition-[opacity,translate] duration-[220ms] ease-out-expo\",\n        \"data-starting-style:opacity-0 data-starting-style:data-[activation-direction=down]:translate-y-1.5 data-starting-style:data-[activation-direction=up]:-translate-y-1.5\",\n        \"data-ending-style:pointer-events-none data-ending-style:absolute data-ending-style:inset-x-0 data-ending-style:top-0 data-ending-style:opacity-0 data-ending-style:duration-[120ms] data-ending-style:ease-out-quart\",\n        \"group-data-[nav=key]/vtabs:duration-0\",\n        className,\n      )}\n      {...rest}\n    />\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/vertical-tabs"
}
