All components lineenclosedoutlinepillsghostxssmmdlg
Tabs
Switch between panels with line, enclosed, outline, pills, and ghost tab lists.
12345678910111213141516171819import { Tabs } from "#components/ui/base-ui/tabs.tsx";
export function Example() { return ( // variant/size resolve once on Root; every part inherits them via // context — selection, roving focus, and keyboard come from the machine <Tabs.Root defaultValue="overview" variant="line" size="md"> <Tabs.List> <Tabs.Tab value="overview">Overview</Tabs.Tab> <Tabs.Tab value="members">Members</Tabs.Tab> </Tabs.List> <Tabs.Panel value="overview">…</Tabs.Panel> <Tabs.Panel value="members">…</Tabs.Panel> </Tabs.Root> );}
// controlled: <Tabs.Root value={value} onValueChange={setValue}>// composition per part via renderVariants
Sizes
Install
1npx stylexblocks add tabsSource
components/ui/base-ui/tabs.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480import * as stylex from "@stylexjs/stylex";import { Tabs as BaseTabs } from "@base-ui-components/react/tabs";import { createSlotRecipeContext } from "#lib/base-ui/create-slot-recipe-context.tsx";import { defineSlotRecipe, fromMap, resolveSlotStyles,} from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { fontSizes } from '@stylexblocks/ui/tokens/font-sizes.stylex.ts'import { fontWeights } from '@stylexblocks/ui/tokens/font-weights.stylex.ts'import { lineHeights } from '@stylexblocks/ui/tokens/line-heights.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { semanticColors } from '@stylexblocks/ui/semantic-tokens/colors.stylex.ts'import { semanticRadii } from '@stylexblocks/ui/semantic-tokens/radii.stylex.ts'import { semanticShadows } from '@stylexblocks/ui/semantic-tokens/shadows.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const tabsRoot = stylex.create({ base: { '--tabs-trigger-radius': semanticRadii.control, position: 'relative', },})
const tabsRootSizes = stylex.create({ xs: { '--tabs-height': sizes.controlXs, '--tabs-content-padding': spacing._2, }, sm: { '--tabs-height': sizes.controlSm, '--tabs-content-padding': spacing._3, }, md: { '--tabs-height': sizes.controlMd, '--tabs-content-padding': spacing._4, }, lg: { '--tabs-height': sizes.controlLg, '--tabs-content-padding': spacing._4_5, },})
const tabsList = stylex.create({ base: { display: 'inline-flex', position: 'relative', isolation: 'isolate', '--tabs-indicator-shadow': semanticShadows.xs, '--tabs-indicator-bg': semanticColors.bg, minHeight: 'var(--tabs-height)', },})
const tabsListVariants = stylex.create({ line: { display: 'flex', borderColor: semanticColors.border, }, pills: { gap: spacing._2, }, ghost: { gap: spacing._2, }, enclosed: { backgroundColor: semanticColors.bgMuted, boxShadow: semanticShadows.inset, borderRadius: radii.md, borderWidth: '1px', padding: spacing._1, minHeight: 'calc(var(--tabs-height) - 4px)', }, outline: { '--line-thickness': '1px', '--line-offset': 'calc(var(--line-thickness) * -1)', borderColor: semanticColors.border, display: 'flex', },})
const tabsListFitteds = stylex.create({ true: { display: 'flex', },})
const tabsListJustifys = stylex.create({ start: { justifyContent: 'flex-start', }, center: { justifyContent: 'center', }, end: { justifyContent: 'flex-end', },})
const tabsTrigger = stylex.create({ base: { outline: 'none', minWidth: 'var(--tabs-height)', height: 'var(--tabs-height)', display: 'flex', alignItems: 'center', fontWeight: fontWeights.medium, position: 'relative', cursor: { default: cursor.button, ':is(:disabled, [disabled], [data-disabled])': 'not-allowed', }, gap: spacing._2, zIndex: { default: null, ':focus-visible': 1, }, outlineWidth: { default: null, ':focus-visible': '1px', }, outlineOffset: { default: null, ':focus-visible': '2px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, opacity: { default: null, ':is(:disabled, [disabled], [data-disabled])': 0.5, }, },})
const tabsTriggerSizes = stylex.create({ xs: { paddingBlock: spacing._1, paddingInline: spacing._2, fontSize: fontSizes.xs, lineHeight: lineHeights.xs, }, sm: { paddingBlock: spacing._1, paddingInline: spacing._3, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, md: { paddingBlock: spacing._2, paddingInline: spacing._4, fontSize: fontSizes.sm, lineHeight: lineHeights.sm, }, lg: { paddingBlock: spacing._2, paddingInline: spacing._4_5, fontSize: fontSizes.md, lineHeight: lineHeights.md, },})
const tabsTriggerVariants = stylex.create({ line: { color: { default: semanticColors.fgMuted, ':hover': semanticColors.fgSubtle, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': semanticColors.fg, }, boxShadow: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': `inset 0 -2px 0 0 ${colorPalette.solid}`, }, backgroundColor: { default: null, ':is(:disabled, [disabled], [data-disabled])': { default: null, ':active': 'initial', }, }, }, subtle: { borderRadius: 'var(--tabs-trigger-radius)', color: { default: semanticColors.fgMuted, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': colorPalette.fg, }, backgroundColor: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': colorPalette.muted, }, }, pills: { borderRadius: 'var(--tabs-trigger-radius)', color: { default: semanticColors.fgMuted, ':hover': semanticColors.fgSubtle, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': semanticColors.fg, }, borderWidth: '1px', borderColor: semanticColors.borderEmphasized, backgroundColor: { default: null, ':hover': semanticColors.bgMuted, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-bg)', }, boxShadow: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-shadow)', }, }, ghost: { borderRadius: 'var(--tabs-trigger-radius)', color: { default: semanticColors.fgSubtle, ':hover': colorPalette.fg, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': colorPalette.fg, }, backgroundColor: { default: null, ':hover': colorPalette.muted, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': colorPalette.muted, }, }, enclosed: { justifyContent: 'center', color: { default: semanticColors.fgMuted, ':hover': semanticColors.fgSubtle, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': semanticColors.fg, }, borderRadius: `calc(${radii.md} - 1px)`, backgroundColor: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-bg)', }, boxShadow: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-shadow)', }, }, outline: { color: { default: semanticColors.fgMuted, ':hover': semanticColors.fgSubtle, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': semanticColors.fg, }, borderWidth: '1px', borderColor: 'transparent', backgroundColor: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-bg)', }, boxShadow: { default: null, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': 'var(--tabs-indicator-shadow)', }, }, plain: { color: { default: semanticColors.fgMuted, ':is([data-selected], [aria-selected="true"], [data-state="active"], [data-active])': semanticColors.fg, }, borderRadius: 'var(--tabs-trigger-radius)', },})
const tabsTriggerFitteds = stylex.create({ true: { flex: 1, textAlign: 'center', justifyContent: 'center', },})
const tabsContent = stylex.create({ base: { outlineWidth: { default: null, ':focus-visible': 'var(--focus-ring-width, 0)', }, outlineOffset: { default: null, ':focus-visible': '0px', }, outlineStyle: { default: null, ':focus-visible': 'solid', }, outlineColor: { default: null, ':focus-visible': colorPalette.focusRing, }, },})
const tabsIndicator = stylex.create({ base: { width: 'var(--width)', height: 'var(--height)', borderRadius: 'var(--tabs-indicator-radius)', backgroundColor: 'var(--tabs-indicator-bg)', boxShadow: 'var(--tabs-indicator-shadow)', zIndex: -1, },})
export type TabsFitted = keyof typeof tabsListFitteds
export type TabsJustify = keyof typeof tabsListJustifys
export type TabsSize = keyof typeof tabsRootSizes
export type TabsVariant = keyof typeof tabsListVariants
// What the generated recipe doesn't carry: the tab's browser-button reset,// the panel padding fed by the root's `--tabs-content-padding`, and the// selected-tab treatment per variant (palette-aware). StyleX keys those off// `data-selected` / `data-active` — the engine stamps `data-active`; chrome// also matches it so selected styles apply without a second recipe context.const chrome = stylex.create({ triggerReset: { appearance: "none", borderWidth: 0, backgroundColor: "transparent", }, content: { padding: "var(--tabs-content-padding)" },});
const tabsTriggerSelected = stylex.create({ line: { color: { default: null, ":is([data-selected], [data-active])": semanticColors.fg, }, boxShadow: { default: null, ":is([data-selected], [data-active])": `inset 0 -2px 0 0 ${colorPalette.solid}`, }, }, enclosed: { backgroundColor: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-bg)", }, color: { default: null, ":is([data-selected], [data-active])": semanticColors.fg, }, boxShadow: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-shadow)", }, }, outline: { backgroundColor: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-bg)", }, color: { default: null, ":is([data-selected], [data-active])": semanticColors.fg, }, boxShadow: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-shadow)", }, }, pills: { backgroundColor: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-bg)", }, color: { default: null, ":is([data-selected], [data-active])": semanticColors.fg, }, boxShadow: { default: null, ":is([data-selected], [data-active])": "var(--tabs-indicator-shadow)", }, }, ghost: { backgroundColor: { default: null, ":is([data-selected], [data-active])": colorPalette.muted, }, color: { default: null, ":is([data-selected], [data-active])": colorPalette.fg, }, }, subtle: { backgroundColor: { default: null, ":is([data-selected], [data-active])": colorPalette.muted, }, color: { default: null, ":is([data-selected], [data-active])": colorPalette.fg, }, }, plain: { color: { default: null, ":is([data-selected], [data-active])": semanticColors.fg, }, },});
// defineSlotRecipe validates the shape at compile time: default values must// exist in the axis maps, and slot keys must be `styles` or a declared axis.// The list's variant map is sparse (no `subtle`/`plain`) — the mechanical// resolver skips axis values a slot doesn't style.const tabsSlotRecipe = defineSlotRecipe({ slots: { root: { styles: tabsRoot, sizes: tabsRootSizes }, list: { styles: tabsList, variants: tabsListVariants }, trigger: { styles: tabsTrigger, sizes: tabsTriggerSizes, variants: tabsTriggerVariants, }, content: { styles: tabsContent }, indicator: { styles: tabsIndicator }, }, defaultVariants: { size: "md", variant: "line" },});
const { withProvider, withContext } = createSlotRecipeContext(tabsSlotRecipe);
/** * Tabs — Base UI's tabs machine wearing the generated recipe. `<Tabs.Root * variant size>` resolves the recipe once and every part inherits it through * context; behavior (roving focus, keyboard activation, controlled value) is * the machine's. Composition per part via `render`. */export const Tabs = { Root: withProvider(BaseTabs.Root, "root", "TabsRoot"), List: withContext(BaseTabs.List, "list", "TabsList"), Tab: withContext( BaseTabs.Tab, (v) => [ chrome.triggerReset, ...resolveSlotStyles(tabsSlotRecipe.slots.trigger, v), fromMap(tabsTriggerSelected, v.variant), ], "TabsTab", ), Panel: withContext( BaseTabs.Panel, () => [tabsContent.base, chrome.content], "TabsPanel", ), Indicator: withContext(BaseTabs.Indicator, "indicator", "TabsIndicator"),};