# Tabs

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

```tsx
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

1. Install the following dependencies:

   ```bash
   npm install @base-ui/react @stylexjs/stylex clsx
   ```

2. Set up the token layer and the StyleX compiler, if you have not already. See
   [Installation](https://carbon.style/docs/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.

3. Copy and paste the following code into your project.

   ```tsx title="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 }
   ```

4. Update the import paths to match your project setup.

## Usage

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

```tsx
<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

```text
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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>
  )
}
```

> **Note**
>
> 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](https://base-ui.com/react/components/tabs). 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

| Prop            | Type                         | Default        |
| --------------- | ---------------------------- | -------------- |
| `orientation`   | `"horizontal" \| "vertical"` | `"horizontal"` |
| `defaultValue`  | `any`                        | `0`            |
| `value`         | `any`                        | —              |
| `onValueChange` | `(value, event) => void`     | —              |
| `xstyle`        | `StyleXStyles`               | —              |
| `className`     | `string`                     | —              |

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

| Prop              | Type                  | Default     |
| ----------------- | --------------------- | ----------- |
| `variant`         | `"default" \| "line"` | `"default"` |
| `activateOnFocus` | `boolean`             | `false`     |
| `loopFocus`       | `boolean`             | `true`      |

### TabsTrigger

| Prop           | Type                         | Default |
| -------------- | ---------------------------- | ------- |
| `value`        | `any`                        | index   |
| `iconPosition` | `"start" \| "end" \| "both"` | —       |
| `disabled`     | `boolean`                    | `false` |
| `nativeButton` | `boolean`                    | `true`  |
| `style`        | `CSSProperties`              | —       |

### TabsContent

| Prop          | Type      | Default |
| ------------- | --------- | ------- |
| `value`       | `any`     | index   |
| `keepMounted` | `boolean` | `false` |

### tabsStyles

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

| Key        | What it is                                         |
| ---------- | -------------------------------------------------- |
| `list`     | The row: shape, inset, fill, idle colour           |
| `variants` | The `default` well and `line` list styles, by name |
| `trigger`  | The 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

| Attribute                         | On              | Effect                                      |
| --------------------------------- | --------------- | ------------------------------------------- |
| `data-slot="tabs"`                | the root        | Targets the group from a parent             |
| `data-slot="tabs-list"`           | the row         | Where the indicator variables are written   |
| `data-slot="tabs-trigger"`        | a tab           | Targets one tab                             |
| `data-slot="tabs-content"`        | a panel         | Targets one panel                           |
| `data-slot="tabs-indicator"`      | the clip or bar | The overlay in `default`, the bar in `line` |
| `data-slot="tabs-indicator-pill"` | the pill        | The box that slides, in `default` only      |
| `data-slot="tabs-indicator-tab"`  | a scenery label | A tab's copy inside the clip layer          |
| `data-variant`                    | the row         | Mirrors the `variant` prop                  |
| `data-orientation`                | every part      | Mirrors the `orientation` prop              |
| `data-active`, `data-disabled`    | a tab           | Base UI writes them, during SSR too         |
| `data-hidden`, `data-index`       | a panel         | Base UI writes them                         |

### CSS variables

| Variable                        | On         | Effect                                                          |
| ------------------------------- | ---------- | --------------------------------------------------------------- |
| `--tabs-indicator-radius`       | `TabsList` | Rounds the pill and the clip together                           |
| `--tabs-indicator-color`        | `TabsList` | The `line` bar's colour                                         |
| `--active-tab-left`, `-top`     | the list   | Base UI measures them; the pill and the clip transition to them |
| `--active-tab-width`, `-height` | the list   | The 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.
