{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "code-tabs",
  "type": "registry:ui",
  "title": "Code Tabs",
  "description": "Package manager or language tabs that remember the choice across every block.",
  "categories": [
    "developer"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/code-block.json",
    "https://www.stealth.pm/r/copy-button.json"
  ],
  "files": [
    {
      "path": "components/ui/code-tabs.tsx",
      "content": "\"use client\";\nimport { Tabs } from \"@base-ui/react/tabs\";\nimport { Tooltip } from \"@base-ui/react/tooltip\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from \"react\";\nimport { CopyButton } from \"@/components/ui/copy-button\";\nimport { CodeLines, Hint, resolveLanguage, tokenize, useSideScroll } from \"@/components/ui/code-block\";\nimport { cn } from \"@/lib/cn\";\nimport { ease } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * A preference shared by every instance with the same key: on this page through an event,\n * in other tabs through the storage event, and across visits through localStorage.\n * -----------------------------------------------------------------------------------------------*/\n\nconst EVENT = \"stealth:preference\";\nconst storageKey = (key: string) => `stealth:${key}`;\n\nfunction read(key: string) {\n  try {\n    return localStorage.getItem(storageKey(key));\n  } catch {\n    return null; // Private windows and blocked storage: behave as if nothing was saved.\n  }\n}\n\n/** Reads and writes a synced preference. Returns null until a value has been chosen (and always on the server). */\nexport function usePreference(key: string | undefined) {\n  const subscribe = useCallback(\n    (notify: () => void) => {\n      if (!key) return () => {};\n      const onStorage = (e: StorageEvent) => e.key === storageKey(key) && notify();\n      const onLocal = (e: Event) => (e as CustomEvent<{ key: string }>).detail?.key === key && notify();\n      window.addEventListener(\"storage\", onStorage);\n      window.addEventListener(EVENT, onLocal);\n      return () => {\n        window.removeEventListener(\"storage\", onStorage);\n        window.removeEventListener(EVENT, onLocal);\n      };\n    },\n    [key],\n  );\n  const value = useSyncExternalStore(\n    subscribe,\n    () => (key ? read(key) : null),\n    () => null,\n  );\n  const set = useCallback(\n    (next: string) => {\n      if (!key) return;\n      try {\n        localStorage.setItem(storageKey(key), next);\n      } catch {}\n      window.dispatchEvent(new CustomEvent(EVENT, { detail: { key, value: next } }));\n    },\n    [key],\n  );\n  return [value, set] as const;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * CodeTabs\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CodeTabsItem = {\n  value: string;\n  label: React.ReactNode;\n  code: string;\n  /** Language name, alias or extension. Falls back to the filename's extension, then plain text. */\n  lang?: string;\n  /** Shown beside the copy button while this tab is active. */\n  filename?: string;\n  /** 14px icon before the label. */\n  icon?: React.ReactNode;\n};\n\nexport type CodeTabsProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  items: CodeTabsItem[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Shares the choice with every CodeTabs using the same key, in this tab, in other tabs,\n   * and on the next visit. Use \"package-manager\" for install commands.\n   */\n  syncKey?: string;\n  lineNumbers?: boolean;\n  /** Accessible name for the tab list, e.g. \"Package manager\". */\n  label?: string;\n};\n\nexport function CodeTabs({\n  items,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  syncKey,\n  lineNumbers = false,\n  label = \"Code variant\",\n  className,\n  onKeyDownCapture,\n  onPointerDownCapture,\n  ...rest\n}: CodeTabsProps) {\n  const reduce = useReducedMotion();\n  const [synced, setSynced] = usePreference(syncKey);\n  const [inner, setInner] = useState(defaultValue ?? items[0]?.value);\n  const has = (v: string | null | undefined): v is string => !!v && items.some((i) => i.value === v);\n  // The prop wins, then the shared choice (if this block offers it), then this block's own state.\n  const value = valueProp !== undefined ? valueProp : has(synced) ? synced : has(inner) ? inner : items[0]?.value;\n  const active = items.find((i) => i.value === value) ?? items[0];\n\n  // One gutter width for every tab, so switching from a 9-line to a 10-line snippet doesn't nudge the code.\n  const digits = String(Math.max(...items.map((i) => i.code.replace(/\\n$/, \"\").split(\"\\n\").length))).length;\n  const list = useRef<HTMLDivElement>(null);\n  const overflows = useSideScroll(list);\n\n  const change = (next: string) => {\n    if (next === value) return;\n    setInner(next);\n    setSynced(next);\n    onValueChange?.(next);\n  };\n\n  if (!active) return null;\n\n  return (\n    <Tabs.Root\n      value={value}\n      onValueChange={(v) => change(String(v))}\n      // Pointer switches animate; arrow keys and changes synced from elsewhere land on the same frame.\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/ct flex min-w-0 flex-col overflow-hidden rounded-xl border border-line bg-raised shadow-[var(--shadow)]\", className)}\n      {...rest}\n    >\n      <div className=\"relative flex h-10 shrink-0 items-stretch pr-1.5 before:pointer-events-none before:absolute before:inset-x-0 before:bottom-0 before:h-px before:bg-line\">\n        <Tabs.List\n          ref={list}\n          aria-label={label}\n          activateOnFocus\n          className={cn(\n            \"relative flex min-w-0 flex-1 items-stretch overflow-x-auto overscroll-x-contain px-1.5 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden\",\n            overflows && \"[mask-image:linear-gradient(to_left,transparent,var(--fg)_var(--fade-end,0px))]\",\n          )}\n        >\n          {items.map((item) => (\n            <Tabs.Tab\n              key={item.value}\n              value={item.value}\n              className={cn(\n                \"group/tab relative flex shrink-0 select-none items-center px-2.5 outline-none\",\n                \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n                \"text-[12.5px] font-medium tracking-[-0.005em] text-fg-3 transition-colors duration-150 hover:text-fg-2 data-active:text-fg\",\n                \"data-disabled:pointer-events-none data-disabled:text-fg-4\",\n              )}\n            >\n              <span\n                className={cn(\n                  \"-mx-1 flex items-center gap-1.5 whitespace-nowrap rounded-md px-1 outline-offset-2\",\n                  \"transition-[scale] duration-150 ease-out-quart group-active/tab:scale-[0.96] group-active/tab:duration-75\",\n                  \"group-focus-visible/tab:outline-solid group-focus-visible/tab:outline-1 group-focus-visible/tab:outline-fg-3\",\n                )}\n              >\n                {item.icon && <span className=\"grid shrink-0 place-items-center text-fg-4 transition-colors group-data-active/tab:text-fg-2 [&_svg]:size-3.5\">{item.icon}</span>}\n                {item.label}\n              </span>\n            </Tabs.Tab>\n          ))}\n          <Tabs.Indicator\n            className={cn(\n              \"pointer-events-none absolute bottom-0 left-0 z-[1] h-px translate-x-(--active-tab-left) bg-fg\",\n              // Inset to the label, so the line sits under the word and not the padding.\n              \"ml-2.5 w-[calc(var(--active-tab-width)-20px)]\",\n              \"transition-none group-data-[nav=pointer]/ct:transition-[translate,width] group-data-[nav=pointer]/ct:duration-[240ms] group-data-[nav=pointer]/ct:ease-in-out-quart\",\n              \"motion-reduce:transition-none\",\n            )}\n          />\n        </Tabs.List>\n\n        <div className=\"flex shrink-0 items-center gap-2 pl-2\">\n          {items.some((i) => i.filename) && (\n            // Every filename shares one cell, so the header never shifts as they swap.\n            <span className=\"hidden font-mono text-[11.5px] text-fg-3 sm:grid\">\n              {items.map((i) => (\n                <span key={i.value} aria-hidden className=\"invisible col-start-1 row-start-1 text-right\">\n                  {i.filename}\n                </span>\n              ))}\n              <AnimatePresence initial={false}>\n                <motion.span\n                  key={active.value}\n                  className=\"col-start-1 row-start-1 text-right\"\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n                >\n                  {active.filename}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n          )}\n          <Tooltip.Provider delay={500}>\n            <Hint label=\"Copy code\">\n              <CopyButton value={active.code.replace(/\\n$/, \"\")} iconOnly variant=\"ghost\" size=\"sm\" label=\"Copy code\" />\n            </Hint>\n          </Tooltip.Provider>\n        </div>\n      </div>\n\n      <Panels>\n        {items.map((item) => (\n          <Tabs.Panel\n            key={item.value}\n            value={item.value}\n            className={cn(\n              \"col-start-1 row-start-1 min-w-0 outline-none\",\n              \"transition-opacity duration-200 ease-out data-starting-style:opacity-0 data-ending-style:opacity-0 data-ending-style:duration-100\",\n              \"group-data-[nav=key]/ct:transition-none\",\n            )}\n          >\n            <Code item={item} lineNumbers={lineNumbers} minDigits={digits} />\n          </Tabs.Panel>\n        ))}\n      </Panels>\n    </Tabs.Root>\n  );\n}\n\n// Glides between panels of different heights instead of snapping.\nfunction Panels({ children }: { children: React.ReactNode }) {\n  const reduce = useReducedMotion();\n  const inner = useRef<HTMLDivElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const el = inner.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => setHeight(el.offsetHeight));\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n  return (\n    <motion.div\n      initial={false}\n      animate={{ height }}\n      transition={reduce ? { duration: 0 } : { duration: 0.26, ease: ease.inOut }}\n      className=\"overflow-hidden\"\n    >\n      <div ref={inner} className=\"grid\">\n        {children}\n      </div>\n    </motion.div>\n  );\n}\n\nfunction Code({ item, lineNumbers, minDigits }: { item: CodeTabsItem; lineNumbers: boolean; minDigits: number }) {\n  const scroller = useRef<HTMLPreElement>(null);\n  const overflows = useSideScroll(scroller);\n  const lines = useMemo(() => tokenize(item.code, resolveLanguage(item.lang, item.filename)), [item.code, item.lang, item.filename]);\n  return (\n    <pre\n      ref={scroller}\n      tabIndex={overflows ? 0 : undefined}\n      className={cn(\n        \"m-0 overflow-x-auto overscroll-x-contain py-3 outline-none\",\n        \"focus-visible:shadow-[inset_0_0_0_1px_var(--fg-3)]\",\n        \"[mask-image:linear-gradient(to_left,transparent,var(--fg)_var(--fade-end,0px))]\",\n      )}\n    >\n      <CodeLines lines={lines} lineNumbers={lineNumbers} minDigits={minDigits} />\n    </pre>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/code-tabs"
}
