Tabs

A set of layered sections of content, known as tab panels, that are displayed one at a time.

Overview
Your key metrics and recent project activity, in one place.
You have 12 active projects and 3 pending tasks.
import * as stylex from "@stylexjs/stylex"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  tabs: { width: "100%", maxWidth: "26rem" },
  body: { color: color.mutedForeground },
})

export default function TabsDemo() {
  return (
    <Tabs defaultValue="overview" xstyle={styles.tabs}>
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="reports">Reports</TabsTrigger>
        <TabsTrigger value="settings">Settings</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <Card>
          <CardHeader>
            <CardTitle>Overview</CardTitle>
            <CardDescription>
              Your key metrics and recent project activity, in one place.
            </CardDescription>
          </CardHeader>
          <CardContent xstyle={styles.body}>
            You have 12 active projects and 3 pending tasks.
          </CardContent>
        </Card>
      </TabsContent>
      <TabsContent value="analytics">
        <Card>
          <CardHeader>
            <CardTitle>Analytics</CardTitle>
            <CardDescription>Performance and engagement over the last 30 days.</CardDescription>
          </CardHeader>
          <CardContent xstyle={styles.body}>
            Page views are up 25% compared to last month.
          </CardContent>
        </Card>
      </TabsContent>
      <TabsContent value="reports">
        <Card>
          <CardHeader>
            <CardTitle>Reports</CardTitle>
            <CardDescription>Generate and download your detailed reports.</CardDescription>
          </CardHeader>
          <CardContent xstyle={styles.body}>You have 5 reports ready to export.</CardContent>
        </Card>
      </TabsContent>
      <TabsContent value="settings">
        <Card>
          <CardHeader>
            <CardTitle>Settings</CardTitle>
            <CardDescription>Account preferences for this workspace.</CardDescription>
          </CardHeader>
          <CardContent xstyle={styles.body}>
            Configure notifications, security, and themes.
          </CardContent>
        </Card>
      </TabsContent>
    </Tabs>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts holds a trigger's focus ring, and a trigger reads its Base UI state through state-styles.ts. Tabs also need interop.css, which sizes an icon placed inside a trigger.

Copy and paste the following code into your project.

components/ui/tabs.tsx
"use client"

import { createContext, use } from "react"
import type { CSSProperties } from "react"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"

import { stateStyles } from "@/lib/state-styles"
import { states } from "@/theme/states"
import { color, duration, ease, leading, radius, space, text } from "@/theme/tokens.stylex"

const tabsTriggerIdle = `light-dark(color-mix(in oklab, ${color.foreground} 60%, transparent), ${color.mutedForeground})`

const TabsIndicatorContext = createContext(false)

const TabsOrientationContext = createContext<"horizontal" | "vertical">("horizontal")
const TabsVariantContext = createContext<TabsVariant>("default")

type TabsVariant = "default" | "line"

const pillRadius = `var(--tabs-indicator-radius, calc(${radius.lg} - 3px))`

const styles = stylex.create({
  root: { display: "flex", gap: space.s2 },
  rootHorizontal: { flexDirection: "column" },
  rootVertical: { flexDirection: "row" },

  list: {
    position: "relative",
    display: "inline-flex",
    height: space.s9,
    width: "fit-content",
    alignItems: "center",
    justifyContent: "center",
    borderRadius: radius.lg,
    padding: "3px",
    color: color.mutedForeground,
  },
  listVertical: { height: "fit-content", flexDirection: "column" },

  trigger: {
    position: "relative",
    display: "inline-flex",
    height: "100%",
    flexGrow: 1,
    flexBasis: 0,
    alignItems: "center",
    justifyContent: "center",
    gap: space.s2,
    borderRadius: pillRadius,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInline: space.s3,
    paddingBlock: space.s1,
    fontSize: text.sm,
    lineHeight: leading.sm,
    fontWeight: 500,
    whiteSpace: "nowrap",
    color: { default: tabsTriggerIdle, ":hover": color.foreground },
    transitionProperty: "color, background-color, border-color",
    transitionDuration: duration.color,
    outlineStyle: { default: "none", ":focus-visible": "solid" },
    outlineWidth: { default: null, ":focus-visible": "1px" },
    outlineColor: { default: null, ":focus-visible": color.ring },
    pointerEvents: { default: null, ":disabled": "none" },
    opacity: { default: 1, ":disabled": 0.5 },
    "--carbon-icon-size": space.s4,
  },
  triggerVertical: { width: "100%", justifyContent: "flex-start" },
  triggerActive: { color: color.foreground },
  triggerIconStart: { paddingInlineStart: space.s2 },
  triggerIconEnd: { paddingInlineEnd: space.s2 },

  pill: {
    pointerEvents: "none",
    position: "absolute",
    borderRadius: pillRadius,
    borderWidth: 1,
    borderStyle: "solid",
    borderColor: "var(--carbon-border-color)",
    boxShadow: "var(--carbon-key-surface)",
    top: "var(--active-tab-top)",
    left: "var(--active-tab-left)",
    height: "var(--active-tab-height)",
    width: "var(--active-tab-width)",
    transitionProperty: "left, top, width, height",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },

  clip: {
    pointerEvents: "none",
    position: "absolute",
    inset: 0,
    userSelect: "none",
    backgroundColor: color.transparent,
    boxShadow: "none",
    clipPath: `inset(var(--active-tab-top, 0px) calc(100% - var(--active-tab-left, 0px) - var(--active-tab-width, 0px)) calc(100% - var(--active-tab-top, 0px) - var(--active-tab-height, 0px)) var(--active-tab-left, 0px) round ${pillRadius})`,
    transitionProperty: "clip-path",
    transitionDuration: {
      default: duration.panel,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.spring,
  },

  line: {
    pointerEvents: "none",
    position: "absolute",
    backgroundColor: `var(--tabs-indicator-color, ${color.foreground})`,
    transitionProperty: "all",
    transitionDuration: {
      default: duration.slide,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.out,
  },
  lineHorizontal: {
    bottom: "-0.5px",
    left: "var(--active-tab-left)",
    height: "2px",
    width: "var(--active-tab-width)",
  },
  lineVertical: {
    top: "var(--active-tab-top)",
    right: "-0.25rem",
    height: "var(--active-tab-height)",
    width: "2px",
  },

  content: { flexGrow: 1, fontSize: text.sm, lineHeight: leading.sm, outlineStyle: "none" },
})

const lineList = stylex.create({
  line: {
    borderRadius: 0,
    gap: space.s1,
    backgroundColor: color.transparent,
    paddingInline: 0,
    "::before": {
      content: "''",
      position: "absolute",
      insetInline: 0,
      bottom: "-0.5px",
      height: 1,
      backgroundColor: color.border,
    },
  },
})

const listVariants = { default: states.well, line: lineList.line }

const tabsStyles = {
  list: styles.list,
  variants: listVariants,
  trigger: [states.focusRing, styles.trigger],
}

type TabsProps = Omit<TabsPrimitive.Root.Props, "className"> & {
  xstyle?: StyleXStyles
  className?: string
}

function Tabs({ xstyle, className, orientation = "horizontal", ...props }: TabsProps) {
  const sx = stylex.props(
    styles.root,
    orientation === "vertical" ? styles.rootVertical : styles.rootHorizontal,
    xstyle,
  )

  return (
    <TabsOrientationContext value={orientation}>
      <TabsPrimitive.Root
        data-slot="tabs"
        data-orientation={orientation}
        orientation={orientation}
        {...sx}
        {...props}
        className={clsx(sx.className, className)}
      />
    </TabsOrientationContext>
  )
}

type TabsListProps = Omit<TabsPrimitive.List.Props, "className"> & {
  variant?: TabsVariant
  xstyle?: StyleXStyles
  className?: string
}

function TabsList({ xstyle, className, variant = "default", children, ...props }: TabsListProps) {
  const orientation = use(TabsOrientationContext)
  const vertical = orientation === "vertical"

  const rowStyles = [styles.list, vertical && styles.listVertical, listVariants[variant], xstyle]
  const sx = stylex.props(...rowStyles)

  return (
    <TabsVariantContext value={variant}>
      <TabsPrimitive.List
        data-slot="tabs-list"
        data-variant={variant}
        {...sx}
        {...props}
        className={clsx(sx.className, className)}
      >
        {children}
        {variant === "default" && (
          <>
            <TabsPrimitive.Indicator
              data-slot="tabs-indicator-pill"
              renderBeforeHydration
              {...stylex.props(states.wellKey, styles.pill)}
            />
            <TabsPrimitive.Indicator
              data-slot="tabs-indicator"
              aria-hidden
              renderBeforeHydration
              {...stylex.props(...rowStyles, styles.clip)}
            >
              <TabsIndicatorContext value={true}>{children}</TabsIndicatorContext>
            </TabsPrimitive.Indicator>
          </>
        )}
        {variant === "line" && (
          <TabsPrimitive.Indicator
            data-slot="tabs-indicator"
            renderBeforeHydration
            {...stylex.props(styles.line, vertical ? styles.lineVertical : styles.lineHorizontal)}
          />
        )}
      </TabsPrimitive.List>
    </TabsVariantContext>
  )
}

type TabsTriggerProps = Omit<TabsPrimitive.Tab.Props, "className" | "style"> & {
  iconPosition?: "start" | "end" | "both"
  xstyle?: StyleXStyles
  style?: CSSProperties
  className?: string
}

function TabsTrigger({
  xstyle,
  style,
  className,
  iconPosition,
  children,
  ...props
}: TabsTriggerProps) {
  const orientation = use(TabsOrientationContext)
  const variant = use(TabsVariantContext)
  const vertical = orientation === "vertical"

  const shared = [
    states.focusRing,
    styles.trigger,
    vertical && styles.triggerVertical,
    (iconPosition === "start" || iconPosition === "both") && styles.triggerIconStart,
    (iconPosition === "end" || iconPosition === "both") && styles.triggerIconEnd,
  ] as const

  if (use(TabsIndicatorContext)) {
    const sx = stylex.props(...shared, styles.triggerActive, xstyle)
    return (
      <span
        data-slot="tabs-indicator-tab"
        data-active=""
        {...sx}
        className={clsx("carbon-icons", sx.className, className)}
        style={{ ...sx.style, ...style }}
      >
        {children}
      </span>
    )
  }

  return (
    <TabsPrimitive.Tab
      data-slot="tabs-trigger"
      {...props}
      {...stateStyles(
        (state) => [
          ...shared,
          variant !== "default" && state.active && styles.triggerActive,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      {children}
    </TabsPrimitive.Tab>
  )
}

type TabsContentProps = Omit<TabsPrimitive.Panel.Props, "className"> & {
  xstyle?: StyleXStyles
  className?: string
}

function TabsContent({ xstyle, className, ...props }: TabsContentProps) {
  const sx = stylex.props(styles.content, xstyle)
  return (
    <TabsPrimitive.Panel
      data-slot="tabs-content"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

export { Tabs, TabsContent, TabsList, tabsStyles, TabsTrigger, type TabsVariant }

Update the import paths to match your project setup.

Usage

import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
<Tabs defaultValue="account">
  <TabsList>
    <TabsTrigger value="account">Account</TabsTrigger>
    <TabsTrigger value="password">Password</TabsTrigger>
  </TabsList>
  <TabsContent value="account">Make changes to your account here.</TabsContent>
  <TabsContent value="password">Change your password here.</TabsContent>
</Tabs>

A trigger and its panel are paired by value. Use defaultValue for an uncontrolled group, or value and onValueChange to control it. A trigger that carries no value falls back to its index, which is why defaultValue is 0.

Composition

Tabs
├── TabsList
│   ├── TabsTrigger
│   └── TabsTrigger
├── TabsContent
└── TabsContent

TabsList renders the indicator itself, so there is no TabsIndicator part to place. The default variant renders two: a pill that slides between tabs, and a copy of the tab row clipped to the active tab. The copy cross-fades the active label's colour under the moving pill.

Line

Use variant="line" on TabsList to swap the pill for a 2px bar and drop the list's fill.

12 active projects and 3 pending tasks.
import * as stylex from "@stylexjs/stylex"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  tabs: { width: "100%", maxWidth: "26rem" },
  body: { color: color.mutedForeground },
})

export default function TabsLine() {
  return (
    <Tabs defaultValue="overview" xstyle={styles.tabs}>
      <TabsList variant="line">
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="reports">Reports</TabsTrigger>
      </TabsList>
      <TabsContent value="overview" xstyle={styles.body}>
        12 active projects and 3 pending tasks.
      </TabsContent>
      <TabsContent value="analytics" xstyle={styles.body}>
        Page views are up 25% compared to last month.
      </TabsContent>
      <TabsContent value="reports" xstyle={styles.body}>
        5 reports ready to export.
      </TabsContent>
    </Tabs>
  )
}

Vertical

Use orientation="vertical" on Tabs to stack the triggers and put the panels beside them. The line bar moves to the list's inline end.

Make changes to your account here.
import * as stylex from "@stylexjs/stylex"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { color } from "@/theme/tokens.stylex"

const styles = stylex.create({
  list: { minWidth: "9rem" },
  body: { color: color.mutedForeground },
})

export default function TabsVertical() {
  return (
    <Tabs defaultValue="account" orientation="vertical">
      <TabsList xstyle={styles.list}>
        <TabsTrigger value="account">Account</TabsTrigger>
        <TabsTrigger value="password">Password</TabsTrigger>
        <TabsTrigger value="notifications">Notifications</TabsTrigger>
      </TabsList>
      <TabsContent value="account" xstyle={styles.body}>
        Make changes to your account here.
      </TabsContent>
      <TabsContent value="password" xstyle={styles.body}>
        Change your password here.
      </TabsContent>
      <TabsContent value="notifications" xstyle={styles.body}>
        Choose what you get emailed about.
      </TabsContent>
    </Tabs>
  )
}

Disabled

Use the disabled prop on TabsTrigger to dim a tab and stop it activating.

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"

export default function TabsDisabled() {
  return (
    <Tabs defaultValue="home">
      <TabsList>
        <TabsTrigger value="home">Home</TabsTrigger>
        <TabsTrigger value="settings" disabled>
          Disabled
        </TabsTrigger>
      </TabsList>
    </Tabs>
  )
}

Icons

Put the icon inside the trigger and set iconPosition to the side it sits on. The tab tightens that padding by 4px so the glyph is not inset as far as a label would be.

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { AppWindow, Code } from "lucide-react"

export default function TabsIcons() {
  return (
    <Tabs defaultValue="preview">
      <TabsList>
        <TabsTrigger value="preview" iconPosition="start">
          <AppWindow />
          Preview
        </TabsTrigger>
        <TabsTrigger value="code" iconPosition="start">
          <Code />
          Code
        </TabsTrigger>
      </TabsList>
    </Tabs>
  )
}

A panel is unmounted while its tab is inactive, so anything typed into it is lost when you switch back. Pass keepMounted to TabsContent to keep the DOM, or lift the value into state above Tabs.

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Tabs part of the same name and accepts its props. See the Base UI reference. Each part also takes xstyle and className. TabsTrigger takes style too. It is real inline CSS, merged over whatever its Base UI state resolves to.

Tabs

PropTypeDefault
orientation"horizontal" | "vertical""horizontal"
defaultValueany0
valueany—
onValueChange(value, event) => void—
xstyleStyleXStyles—
classNamestring—

The root publishes orientation through context, because StyleX has no ancestor selector for the list and the triggers to read it from the DOM.

TabsList

PropTypeDefault
variant"default" | "line""default"
activateOnFocusbooleanfalse
loopFocusbooleantrue

TabsTrigger

PropTypeDefault
valueanyindex
iconPosition"start" | "end" | "both"—
disabledbooleanfalse
nativeButtonbooleantrue
styleCSSProperties—

TabsContent

PropTypeDefault
valueanyindex
keepMountedbooleanfalse

tabsStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
listThe row: shape, inset, fill, idle colour
variantsThe default well and line list styles, by name
triggerThe shared focus ring and one tab's box, in order

trigger is two styles, states.focusRing from theme/states.ts and the tab's own box. The ring comes first, because a tab sets its own outline-style for the 1px line it draws inside the halo, and a shared style takes a property with every one of its conditions.

Data attributes

AttributeOnEffect
data-slot="tabs"the rootTargets the group from a parent
data-slot="tabs-list"the rowWhere the indicator variables are written
data-slot="tabs-trigger"a tabTargets one tab
data-slot="tabs-content"a panelTargets one panel
data-slot="tabs-indicator"the clip or barThe overlay in default, the bar in line
data-slot="tabs-indicator-pill"the pillThe box that slides, in default only
data-slot="tabs-indicator-tab"a scenery labelA tab's copy inside the clip layer
data-variantthe rowMirrors the variant prop
data-orientationevery partMirrors the orientation prop
data-active, data-disableda tabBase UI writes them, during SSR too
data-hidden, data-indexa panelBase UI writes them

CSS variables

VariableOnEffect
--tabs-indicator-radiusTabsListRounds the pill and the clip together
--tabs-indicator-colorTabsListThe line bar's colour
--active-tab-left, -topthe listBase UI measures them; the pill and the clip transition to them
--active-tab-width, -heightthe listThe active tab's box, same source

Set the two --tabs-* properties with style on TabsList. They are plain custom properties, so they inherit to the pill and the clip layer inside it.