Primitives
Tabs
Segmented and browser-style navigation with a stable active indicator.
Composition
Anatomy
- <Tabs>
- <TabsList>
- <TabsTab />
- <TabsPanel />
- <TabsList>
Connected surface
- <Tabs>
- <TabsList>
- <TabsTab />
- <TabsSurface>
- <TabsPanel />
- <TabsList>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/tabs.tsx.
npx shadcn@latest add https://control-ui.dev/r/tabs.jsonRaw code
Primary installed source
"use client";import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";import type { ComponentProps, CSSProperties, ReactNode, Ref } from "react";import { Children, createContext, Fragment, isValidElement, useContext, useEffect, useRef, useState } from "react";import type { ControlledChoice, RenderProp } from "@/components/control-ui/control-props";import type { ControlSize } from "@/components/control-ui/control-variants";import type { TabsKnobStyle } from "@/components/control-ui/knob-contracts/tabs-knobs";import { cn } from "@/components/control-ui/lib/cn";export type TabsProps<TValue extends string = string> = Omit<ComponentProps<"div">, "defaultValue" | "onChange"> & ControlledChoice<TValue> & { orientation?: "horizontal" | "vertical"; style?: CSSProperties & TabsKnobStyle };export type TabsListVariant = "default" | "browser";export type TabsListProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & TabsKnobStyle } & { size?: ControlSize; variant?: TabsListVariant; activateOnFocus?: boolean; loopFocus?: boolean;};export type TabsTabProps = Omit<Omit<ComponentProps<"button">, "value">, "style"> & { style?: CSSProperties & TabsKnobStyle } & { value: string; render?: RenderProp<ComponentProps<"button">, { active: boolean; disabled: boolean }>; nativeButton?: boolean;};export type TabsPanelProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & TabsKnobStyle } & { value: string; keepMounted?: boolean;};export type TabsSurfaceProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & TabsKnobStyle };export function TabsSurface({ className, ...props }: TabsSurfaceProps) { return ( <div data-control-ui="tabs" data-control-family="tabs" data-slot="surface" data-surface="panel" className={className} {...props} /> );}type RegisterTabsPanel = (value: string, node: HTMLDivElement | null) => (() => void) | undefined;type ActiveTabsPanel = { value: string; node: HTMLDivElement; height: number };type TabsPanelsContextValue = { registerPanel: RegisterTabsPanel; exitingValue: string | null };function refreshTabsPanelHeight(panel: ActiveTabsPanel) { if (panel.node.inert || panel.node.hidden) return; panel.height = panel.node.getBoundingClientRect().height;}function setTabsPanelsStarting(panels: HTMLDivElement[], starting: boolean) { for (const panel of panels) panel.toggleAttribute("data-slide-starting", starting);}const TabsPanelsContext = createContext<TabsPanelsContextValue | null>(null);function useTabsPanels() { const context = useContext(TabsPanelsContext); if (!context) throw new Error("TabsPanel must be rendered inside <Tabs>."); return context;}function setRef<T>(ref: Ref<T> | undefined, value: T | null) { if (typeof ref === "function") return ref(value); if (ref) ref.current = value;}export function Tabs<TValue extends string = string>({ className, onValueChange, children, ...props }: TabsProps<TValue>) { const rootRef = useRef<HTMLDivElement>(null); const activePanelRef = useRef<ActiveTabsPanel | null>(null); const startingPanelsRef = useRef<HTMLDivElement[]>([]); const previousHeightRef = useRef<string | null>(null); const clearPreviousHeightFrame = useRef(0); const [exitingValue, setExitingValue] = useState<string | null>(null); const [holdPreviousHeight] = useState(() => (height: string) => { const root = rootRef.current; if (!root) return; previousHeightRef.current = height; cancelAnimationFrame(clearPreviousHeightFrame.current); clearPreviousHeightFrame.current = requestAnimationFrame(() => { clearPreviousHeightFrame.current = requestAnimationFrame(() => { previousHeightRef.current = null; activePanelRef.current?.node.style.removeProperty("--_tabs-prev-height"); setTabsPanelsStarting(startingPanelsRef.current, false); startingPanelsRef.current = []; }); }); }); useEffect(() => () => cancelAnimationFrame(clearPreviousHeightFrame.current), []); const [registerPanel] = useState<RegisterTabsPanel>(() => (value: string, node: HTMLDivElement | null) => { const panelCanBeMeasured = node && !node.inert && !node.hidden; if (!panelCanBeMeasured) return; const previousPanel = activePanelRef.current; const root = rootRef.current; const switchingPanels = root && previousPanel && previousPanel.value !== value; if (switchingPanels) { setTabsPanelsStarting(startingPanelsRef.current, false); startingPanelsRef.current = [previousPanel.node, node]; setTabsPanelsStarting(startingPanelsRef.current, true); const previousHeight = previousHeightRef.current || `${previousPanel.height}px`; node.style.setProperty("--_tabs-prev-height", previousHeight); holdPreviousHeight(previousHeight); setExitingValue(previousPanel.value); } activePanelRef.current = { value, node, height: node.getBoundingClientRect().height }; if (switchingPanels) { const { offsetTop, offsetLeft, offsetWidth, offsetHeight } = node; previousPanel.node.style.setProperty("--_tabs-panel-top", `${offsetTop}px`); previousPanel.node.style.setProperty("--_tabs-panel-left", `${offsetLeft}px`); previousPanel.node.style.setProperty("--_tabs-panel-width", `${offsetWidth}px`); previousPanel.node.style.setProperty("--_tabs-panel-height", `${offsetHeight}px`); } return () => { const currentPanel = activePanelRef.current; if (currentPanel && currentPanel.node === node) refreshTabsPanelHeight(currentPanel); }; }); const handleValueChange = (value: TValue) => { const activePanel = activePanelRef.current; if (activePanel) { refreshTabsPanelHeight(activePanel); holdPreviousHeight(`${activePanel.height}px`); setExitingValue(activePanel.value); } onValueChange?.(value); }; return ( <TabsPanelsContext.Provider value={{ registerPanel, exitingValue }}> <TabsPrimitive.Root data-control-ui="tabs" data-control-family="tabs" data-slot="root" data-slide="scope" className={className} onValueChange={handleValueChange} {...props} ref={rootRef} > {children} </TabsPrimitive.Root> </TabsPanelsContext.Provider> );}const controlHeights: Record<ControlSize, string> = { xs: "var(--control-h-xs)", sm: "var(--control-h-sm)", md: "var(--control-h-md)", lg: "var(--control-h-lg)",};type TabsListStyle = CSSProperties & { "--_tabs-trigger-h"?: string;};function countTabs(children: ReactNode): number { return Children.toArray(children).reduce<number>((count, child) => { if (!isValidElement<{ children?: ReactNode }>(child)) return count; if (child.type === TabsTab) return count + 1; if (child.type === Fragment) return count + countTabs(child.props.children); return count; }, 0);}export function TabsList({ size = "sm", variant = "default", className, children, style, ...props }: TabsListProps) { const isSingle = countTabs(children) === 1; const controlStyle = { "--_tabs-trigger-h": controlHeights[size], ...style, } satisfies TabsListStyle; return ( <TabsPrimitive.List data-control-ui="tabs" data-control-family="tabs" data-slot="list" data-size={size} data-variant={variant} data-single={isSingle ? "true" : undefined} className={cn(variant === "browser" && "gap-0 p-0 px-(--_tabs-browser-inset) pt-(--cui-tabs-list-padding)", className)} style={controlStyle} {...props} > {children} {isSingle ? null : ( <TabsPrimitive.Indicator data-control-ui="tabs" data-control-family="tabs" data-slot="indicator" className={ variant === "browser" ? "top-auto bottom-0 left-(--_tabs-browser-indicator-x) z-0 h-(--_tabs-trigger-h) w-(--_tabs-browser-indicator-width) transform-none" : undefined } /> )} </TabsPrimitive.List> );}export function TabsTab({ className, ...props }: TabsTabProps) { return <TabsPrimitive.Tab data-control-ui="tabs" data-control-family="tabs" data-slot="tab" className={className} {...props} />;}export function TabsPanel({ className, value, ref, ...props }: TabsPanelProps) { const { registerPanel, exitingValue } = useTabsPanels(); const panelRef = (node: HTMLDivElement | null) => { const unregister = registerPanel(value, node); const cleanup = setRef(ref, node); if (!node) return; return () => { unregister?.(); if (typeof cleanup === "function") cleanup(); else setRef(ref, null); }; }; return ( <TabsPrimitive.Panel ref={panelRef} value={value} data-control-ui="tabs" data-control-family="tabs" data-slot="panel" data-slide="panel" data-slide-exiting={exitingValue === value ? "" : undefined} className={cn( "data-[hidden]:hidden [&[hidden]]:hidden [&[inert]:not([data-slide=panel][data-slide-exiting][data-ending-style])]:hidden [&[inert][data-ending-style]]:m-0", "not-supports-[anchor-name:--aui-slide-panel]:[&[inert]]:hidden not-supports-[anchor-scope:--aui-slide-panel]:[&[inert]]:hidden", className, )} {...props} /> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-tabs-* · 16 knobsHow the cascade resolves--cui-tabs-surface-background<color>var(--background)--cui-tabs-surface-radius<length-percentage>var(--radius-panel)--cui-tabs-trigger-radius<length-percentage>var(--radius-control)--cui-tabs-list-padding<length-percentage>calc(var(--spacing) * 1)--cui-tabs-list-radius<length-percentage>calc(
var(--cui-tabs-trigger-radius) +
clamp(0px, calc(var(--cui-tabs-trigger-radius) * 1000), var(--cui-tabs-list-padding))
)--cui-tabs-list-background<color>var(--hover-fill)--cui-tabs-list-backdrop-filter*none--cui-tabs-indicator-background<color>var(--background)--cui-tabs-foreground<color>var(--muted-foreground)--cui-tabs-active-foreground<color>var(--foreground)--cui-tabs-hover-foreground<color>var(--foreground)--cui-tabs-indicator-shadow*var(--shadow-sm)--cui-tabs-list-shadow*inset 0 0 0 var(--control-rim-width) var(--control-rim), var(--shadow-inset)--cui-tabs-border-color<color>var(--border)--cui-tabs-border-width<length>var(--control-rim-width)--cui-tabs-indicator-background-image*none