{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "tabs",
  "type": "registry:ui",
  "title": "Tabs",
  "description": "Tabs built on Base UI; a motion indicator springs to the active tab.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://seamui.dev/r/utils.json",
    "https://seamui.dev/r/motion.json",
    "https://seamui.dev/r/button.json",
    "https://seamui.dev/r/haptics.json"
  ],
  "files": [
    {
      "path": "registry/seam/ui/tabs.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Tabs as BaseTabs } from \"@base-ui/react/tabs\"\nimport { motion } from \"motion/react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { springs, reduced, useReducedMotion } from \"@/lib/motion\"\nimport { useHaptics } from \"@/lib/haptics\"\nimport { buttonVariants } from \"./button\"\n\ntype TabsSize = \"default\" | \"sm\"\n\n// Size flows from the Root down to the List/Trigger via context so callers\n// only set it in one place: <Tabs size=\"sm\">.\nconst TabsSizeContext = React.createContext<TabsSize>(\"default\")\n\n// useLayoutEffect on the client (position the indicator before the first\n// painted frame), useEffect on the server (where it would only warn).\nconst useIsomorphicLayoutEffect =\n  typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect\n\nfunction Tabs({\n  className,\n  size = \"default\",\n  onValueChange,\n  ...props\n}: React.ComponentProps<typeof BaseTabs.Root> & { size?: TabsSize }) {\n  // Switching tabs commits a selection — fire the seam tick (§3b).\n  const { trigger } = useHaptics()\n  return (\n    <TabsSizeContext.Provider value={size}>\n      <BaseTabs.Root\n        data-slot=\"tabs\"\n        className={cn(\"flex flex-col gap-2\", className)}\n        onValueChange={(\n          ...args: Parameters<NonNullable<typeof onValueChange>>\n        ) => {\n          trigger(\"tick\")\n          onValueChange?.(...args)\n        }}\n        {...props}\n      />\n    </TabsSizeContext.Provider>\n  )\n}\n\nfunction TabsList({\n  className,\n  children,\n  ref,\n  ...props\n}: React.ComponentProps<typeof BaseTabs.List>) {\n  const size = React.useContext(TabsSizeContext)\n  const reduceMotion = useReducedMotion()\n  const listRef = React.useRef<HTMLDivElement>(null)\n\n  // The list needs its own ref to measure the selected tab, but callers pass\n  // one too (scrolling the strip into view, say). Compose them — spreading\n  // `{...props}` over `ref` would let theirs win and leave the indicator\n  // permanently unmeasured, so no active key would ever be drawn.\n  const attachRef = React.useCallback(\n    (node: HTMLDivElement | null) => {\n      listRef.current = node\n      if (typeof ref === \"function\") ref(node)\n      else if (ref) ref.current = node\n    },\n    [ref]\n  )\n  const [box, setBox] = React.useState<{ left: number; width: number } | null>(\n    null\n  )\n\n  // The indicator is ONE element owned by the list, tracking the selected\n  // tab's horizontal box — not a `layoutId` handing off between per-tab\n  // elements. Shared-layout projection interpolates the full box, so it\n  // animated a vertical component too (a visible dip on the way across);\n  // here `y` can't move, because only `x`/`width` are ever animated.\n  useIsomorphicLayoutEffect(() => {\n    const list = listRef.current\n    if (!list) return\n\n    const measure = () => {\n      // Keyed on aria-selected — the ARIA contract, not Base UI's internal\n      // `data-active` naming.\n      const active = list.querySelector<HTMLElement>(\n        '[data-slot=\"tabs-trigger\"][aria-selected=\"true\"]'\n      )\n      setBox(\n        active ? { left: active.offsetLeft, width: active.offsetWidth } : null\n      )\n    }\n\n    measure()\n    // Re-measure when selection moves, and when the list reflows (a resize,\n    // a font swap, tabs added or removed).\n    const selection = new MutationObserver(measure)\n    selection.observe(list, {\n      subtree: true,\n      attributes: true,\n      attributeFilter: [\"aria-selected\"],\n      childList: true,\n    })\n    const resize = new ResizeObserver(measure)\n    resize.observe(list)\n    return () => {\n      selection.disconnect()\n      resize.disconnect()\n    }\n  }, [])\n\n  return (\n    <BaseTabs.List\n      ref={attachRef}\n      data-slot=\"tabs-list\"\n      className={cn(\n        // recessed well — grouped controls sit below the surface; the active\n        // one rises as a white key (seam design language).\n        \"bg-muted text-muted-foreground shadow-well relative inline-flex w-fit items-center rounded-lg squircle\",\n        size === \"sm\" ? \"gap-0.5 p-1\" : \"gap-1 p-1.5\",\n        className\n      )}\n      {...props}\n    >\n      {box ? (\n        <motion.span\n          aria-hidden\n          data-slot=\"tabs-indicator\"\n          className={cn(\n            \"bg-secondary shadow-resting absolute left-0 z-0 rounded-md squircle\",\n            // inset to the well's padding, so the key sits inside the groove\n            size === \"sm\" ? \"top-1 bottom-1\" : \"top-1.5 bottom-1.5\"\n          )}\n          // `initial={false}` so the indicator appears in place on mount\n          // instead of sliding in from the left on first paint.\n          initial={false}\n          animate={{ x: box.left, width: box.width }}\n          // Reduced motion still moves the key — it just jumps (§5b).\n          transition={reduceMotion ? reduced.instant : springs.snappy}\n        />\n      ) : null}\n      {children}\n    </BaseTabs.List>\n  )\n}\n\nfunction TabsTrigger({\n  className,\n  children,\n  ...props\n}: React.ComponentProps<typeof BaseTabs.Tab>) {\n  const size = React.useContext(TabsSizeContext)\n\n  return (\n    <BaseTabs.Tab\n      data-slot=\"tabs-trigger\"\n      {...props}\n      // The trigger is a button, so it wears the seam Button's own styling —\n      // buttonVariants (ghost + size) is the single source of truth. We reuse\n      // the cva rather than the Button component itself: Base UI's Tab manages\n      // roving focus via the rendered element's ref, and an extra wrapper\n      // breaks arrow-key navigation. The tab keeps its signature — a\n      // transparent key with the active indicator springing between tabs — so\n      // the ghost hover fill is neutralised.\n      render={(tabProps, state) => {\n        const { className: baseClassName, ...rest } =\n          tabProps as React.ComponentProps<\"button\">\n        return (\n          <button\n            {...rest}\n            className={cn(\n              baseClassName,\n              buttonVariants({\n                variant: \"ghost\",\n                size: size === \"sm\" ? \"sm\" : \"default\",\n              }),\n              \"relative hover:bg-transparent\",\n              state.active\n                ? \"text-foreground\"\n                : \"text-muted-foreground hover:text-foreground\",\n              className\n            )}\n          >\n            {/* The active key itself is drawn once by TabsList, which springs\n                it between tabs along x — see the indicator there. */}\n            <span className=\"relative z-10\">{children}</span>\n          </button>\n        )\n      }}\n    />\n  )\n}\n\nfunction TabsContent({\n  className,\n  ...props\n}: React.ComponentProps<typeof BaseTabs.Panel>) {\n  return (\n    <BaseTabs.Panel\n      data-slot=\"tabs-content\"\n      className={cn(\"outline-none\", className)}\n      {...props}\n    />\n  )\n}\n\nexport { Tabs, TabsList, TabsTrigger, TabsContent }\n",
      "type": "registry:ui",
      "target": "components/ui/tabs.tsx"
    }
  ]
}