{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <edu@smoothui.dev>","css":{},"dependencies":["@base-ui/react","motion"],"description":"Animated Tabs primitive for SmoothUI with Base Indicator and underline/pill/segment variants.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { Tabs as TabsPrimitive } from \"@base-ui/react/tabs\";\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  createContext,\n  useContext,\n  useState,\n} from \"react\";\nimport {\n  activeTabIndicatorStyle,\n  composeIndicatorStyle,\n  TabsMeasureValueContext,\n  useActiveTabIndicatorBox,\n} from \"./use-active-tab-indicator\";\n\nexport type TabsVariant = \"underline\" | \"pill\" | \"segment\";\n\nexport type TabsRootProps = ComponentProps<typeof TabsPrimitive.Root> & {\n  className?: string;\n};\n\nexport type TabsListProps = ComponentProps<typeof TabsPrimitive.List> & {\n  /** Visual style for the list + indicator */\n  variant?: TabsVariant;\n  className?: string;\n};\n\nexport type TabsTabProps = ComponentProps<typeof TabsPrimitive.Tab> & {\n  className?: string;\n};\n\nexport type TabsPanelProps = ComponentProps<typeof TabsPrimitive.Panel> & {\n  /** Crossfade panels with opacity when true */\n  animated?: boolean;\n  className?: string;\n};\n\nexport type TabsIndicatorProps = ComponentProps<\n  typeof TabsPrimitive.Indicator\n> & {\n  className?: string;\n};\n\n/** Convenience alias for AutoTypeTable / default root */\nexport type TabsProps = TabsRootProps;\n\nconst TabsVariantContext = createContext<TabsVariant>(\"underline\");\n\nconst listStyles = (variant: TabsVariant) =>\n  cn(\n    \"relative flex min-w-0\",\n    variant === \"underline\" && \"w-full gap-1 border-border border-b\",\n    variant === \"pill\" && \"w-fit gap-1 rounded-full bg-foreground/10 p-1\",\n    variant === \"segment\" && \"w-full gap-0 rounded-lg bg-foreground/10 p-1\"\n  );\n\nconst tabStyles = (variant: TabsVariant) =>\n  cn(\n    \"relative z-10 inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 px-4 py-2.5 font-medium text-sm outline-none\",\n    \"text-muted-foreground transition-colors hover:text-foreground\",\n    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n    \"data-disabled:pointer-events-none data-active:text-foreground data-disabled:text-muted-foreground\",\n    variant === \"underline\" && \"rounded-t-md\",\n    variant === \"pill\" && \"rounded-full\",\n    variant === \"segment\" && \"min-w-0 flex-1 rounded-md\"\n  );\n\nconst indicatorStyles = (variant: TabsVariant, reduceMotion: boolean) =>\n  cn(\n    \"pointer-events-none absolute top-0 left-0 z-0\",\n    reduceMotion\n      ? \"transition-none\"\n      : \"transition-[transform,width,height] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)]\",\n    variant === \"underline\" && \"bg-brand\",\n    variant === \"pill\" &&\n      \"rounded-full border border-foreground/15 bg-background shadow-sm dark:bg-foreground/15\",\n    variant === \"segment\" &&\n      \"rounded-md border border-foreground/15 bg-background shadow-sm dark:bg-foreground/15\"\n  );\n\n/**\n * SmoothUI Tabs — Base UI twin (default).\n * Compound: TabsRoot, TabsList (variant), TabsTab, TabsPanel, TabsIndicator.\n */\nconst TabsRoot = ({\n  className,\n  value,\n  defaultValue,\n  onValueChange,\n  ...props\n}: TabsRootProps) => {\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const current = value === undefined ? uncontrolled : value;\n\n  return (\n    <TabsMeasureValueContext.Provider value={current}>\n      <TabsPrimitive.Root\n        className={cn(\"flex w-full min-w-0 flex-col\", className)}\n        data-slot=\"tabs\"\n        defaultValue={defaultValue}\n        onValueChange={(next, eventDetails) => {\n          if (value === undefined) {\n            setUncontrolled(next);\n          }\n          onValueChange?.(next, eventDetails);\n        }}\n        value={value}\n        {...props}\n      />\n    </TabsMeasureValueContext.Provider>\n  );\n};\n\nconst TabsList = ({\n  className,\n  variant = \"underline\",\n  children,\n  ...props\n}: TabsListProps) => (\n  <TabsVariantContext.Provider value={variant}>\n    <TabsPrimitive.List\n      className={cn(listStyles(variant), className)}\n      data-slot=\"tabs-list\"\n      {...props}\n    >\n      {children}\n    </TabsPrimitive.List>\n  </TabsVariantContext.Provider>\n);\n\nconst TabsTab = ({ className, ...props }: TabsTabProps) => {\n  const variant = useContext(TabsVariantContext);\n  return (\n    <TabsPrimitive.Tab\n      className={cn(tabStyles(variant), className)}\n      data-slot=\"tabs-tab\"\n      {...props}\n    />\n  );\n};\n\nconst TabsPanel = ({\n  className,\n  animated = false,\n  ...props\n}: TabsPanelProps) => {\n  const shouldReduceMotion = useReducedMotion();\n  return (\n    <TabsPrimitive.Panel\n      className={cn(\n        \"min-w-0 pt-4 text-muted-foreground text-sm leading-relaxed outline-none\",\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        // The outgoing panel leaves the layout the moment it starts to exit;\n        // until then it sits in the flow next to the incoming one for a frame.\n        \"data-ending-style:hidden\",\n        // Fade in only. Fading the outgoing panel out keeps it in the flow next\n        // to the incoming one for the whole transition, so the pair stacks and\n        // the tab list and everything below it jump by a panel's height.\n        animated &&\n          (shouldReduceMotion\n            ? \"data-starting-style:opacity-0\"\n            : \"transition-opacity duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] data-starting-style:opacity-0\"),\n        className\n      )}\n      data-slot=\"tabs-panel\"\n      {...props}\n    />\n  );\n};\n\nconst TabsIndicator = ({ className, style, ...props }: TabsIndicatorProps) => {\n  const variant = useContext(TabsVariantContext);\n  const shouldReduceMotion = useReducedMotion();\n  const { box, ref } = useActiveTabIndicatorBox();\n  const measured = activeTabIndicatorStyle(variant, box);\n\n  return (\n    <TabsPrimitive.Indicator\n      className={cn(indicatorStyles(variant, !!shouldReduceMotion), className)}\n      data-slot=\"tabs-indicator\"\n      ref={ref}\n      renderBeforeHydration\n      style={composeIndicatorStyle(measured, style)}\n      {...props}\n    />\n  );\n};\n\nexport { TabsIndicator, TabsList, TabsPanel, TabsRoot, TabsTab };\n\nexport default TabsRoot;\n","path":"index.tsx","target":"components/smoothui/tabs/index.tsx","type":"registry:ui"},{"content":"\"use client\";\n\nimport {\n  type CSSProperties,\n  createContext,\n  useContext,\n  useLayoutEffect,\n  useState,\n} from \"react\";\n\ntype IndicatorVariant = \"underline\" | \"pill\" | \"segment\";\n\ninterface ActiveTabBox {\n  height: number;\n  left: number;\n  top: number;\n  width: number;\n}\n\nconst ACTIVE_TAB_SELECTOR =\n  '[data-slot=\"tabs-tab\"][aria-selected=\"true\"], [data-slot=\"tabs-tab\"][data-active], [data-slot=\"tabs-tab\"][data-state=\"active\"]';\n\nconst UNDERLINE_THICKNESS_PX = 2;\n\nexport const TabsMeasureValueContext = createContext<unknown>(undefined);\n\nconst boxesEqual = (a: ActiveTabBox | null, b: ActiveTabBox | null) => {\n  if (a === b) {\n    return true;\n  }\n  if (!(a && b)) {\n    return false;\n  }\n  return (\n    a.left === b.left &&\n    a.top === b.top &&\n    a.width === b.width &&\n    a.height === b.height\n  );\n};\n\nconst readActiveTabBox = (list: HTMLElement): ActiveTabBox | null => {\n  const tab = list.querySelector<HTMLElement>(ACTIVE_TAB_SELECTOR);\n  if (!tab) {\n    return null;\n  }\n  return {\n    height: tab.offsetHeight,\n    left: tab.offsetLeft,\n    top: tab.offsetTop,\n    width: tab.offsetWidth,\n  };\n};\n\n/**\n * Tracks the active tab's layout box (`offsetLeft` / `offsetTop` /\n * `offsetWidth` / `offsetHeight`) and refreshes on mount, value change,\n * resize, and scroll so the indicator cannot stay invisible or drift.\n */\nexport const useActiveTabIndicatorBox = (): {\n  box: ActiveTabBox | null;\n  ref: (node: HTMLElement | null) => void;\n} => {\n  const value = useContext(TabsMeasureValueContext);\n  const [node, setNode] = useState<HTMLElement | null>(null);\n  const [box, setBox] = useState<ActiveTabBox | null>(null);\n\n  useLayoutEffect(() => {\n    if (!node) {\n      setBox((prev) => (prev === null ? prev : null));\n      return;\n    }\n\n    const list = node.closest<HTMLElement>('[role=\"tablist\"]');\n    if (!list) {\n      return;\n    }\n\n    const measure = () => {\n      const next = readActiveTabBox(list);\n      setBox((prev) => {\n        if ((value === null || value === undefined) && next === null) {\n          return prev === null ? prev : null;\n        }\n        return boxesEqual(prev, next) ? prev : next;\n      });\n    };\n\n    measure();\n\n    const resizeObserver = new ResizeObserver(measure);\n    resizeObserver.observe(list);\n    for (const tab of list.querySelectorAll(\"[data-slot='tabs-tab']\")) {\n      resizeObserver.observe(tab);\n    }\n\n    const mutationObserver = new MutationObserver(measure);\n    mutationObserver.observe(list, {\n      attributeFilter: [\"aria-selected\", \"data-active\", \"data-state\"],\n      attributes: true,\n      subtree: true,\n    });\n\n    list.addEventListener(\"scroll\", measure, { passive: true });\n    window.addEventListener(\"resize\", measure);\n\n    return () => {\n      resizeObserver.disconnect();\n      mutationObserver.disconnect();\n      list.removeEventListener(\"scroll\", measure);\n      window.removeEventListener(\"resize\", measure);\n    };\n  }, [node, value]);\n\n  return { box, ref: setNode };\n};\n\nexport const activeTabIndicatorStyle = (\n  variant: IndicatorVariant,\n  box: ActiveTabBox | null\n): CSSProperties => {\n  if (!box) {\n    return { opacity: 0 };\n  }\n  const isUnderline = variant === \"underline\";\n  const y = isUnderline\n    ? box.top + box.height - UNDERLINE_THICKNESS_PX\n    : box.top;\n  return {\n    height: isUnderline ? UNDERLINE_THICKNESS_PX : box.height,\n    opacity: 1,\n    transform: `translate(${box.left}px, ${y}px)`,\n    width: box.width,\n  };\n};\n\nexport const composeIndicatorStyle = <State>(\n  measured: CSSProperties,\n  style?: CSSProperties | ((state: State) => CSSProperties | undefined)\n): CSSProperties | ((state: State) => CSSProperties) => {\n  if (typeof style === \"function\") {\n    return (state: State) => ({ ...measured, ...style(state) });\n  }\n  return { ...measured, ...style };\n};\n","path":"use-active-tab-indicator.ts","target":"components/smoothui/tabs/use-active-tab-indicator.ts","type":"registry:ui"}],"name":"tabs","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Tabs","type":"registry:ui"}