# Tabs

> Switch between related panels while keeping the same page context.

Source: https://www.honestui.com/docs/components/tabs

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/honest-ui/ui/tabs"

export function TabsDemo() {
  return (
    <Tabs defaultValue="tab-1">
      <TabsList>
        <TabsTab value="tab-1">Tab 1</TabsTab>
        <TabsTab value="tab-2">Tab 2</TabsTab>
        <TabsTab value="tab-3">Tab 3</TabsTab>
      </TabsList>
      <TabsPanel value="tab-1">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 1 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-2">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 2 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-3">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 3 content
        </p>
      </TabsPanel>
    </Tabs>
  )
}

```

## Overview [#overview]

Use Tabs to switch between peer panels without leaving the current page: settings sections, profile views, code examples in different languages, billing periods. Tabs tell people "these are alternative views of the same object" — only one is visible at a time, and switching costs nothing.

Tabs are the wrong tool when panels are steps in a sequence (use a wizard with Back and Next), when people need to compare panels side by side, or when each panel deserves its own URL that can be linked and revisited. If tabs must deep-link, mirror the active tab into the route. See [Don't do this](#dont-do-this).

## Anatomy [#anatomy]

Tabs include a root, a list with an animated indicator, one trigger per tab, and one panel per tab. The trigger labels name their panels; every `value` on a `TabsTab` must match exactly one `TabsPanel` value or the panel will never appear. The indicator slides between triggers to show which panel is active, and it works in both orientations and all variants.

## Behavior [#behavior]

**Orientation.** Horizontal is the default. Use vertical tabs when labels are long or numerous enough that a horizontal row would wrap or truncate — a vertical list keeps every label fully visible.

**Activation mode.** By default activation is manual: arrow keys move focus between triggers, and <kbd>Enter</kbd>, <kbd>Space</kbd>, or a click activates the focused tab. Set `activateOnFocus` on the `TabsList` for automatic activation, where focusing a trigger immediately switches the panel. Reserve automatic mode for panels that render instantly; manual mode prevents slow panels from flashing past while someone arrows across the list.

**Controlled state.** Pass `value` and `onValueChange` to sync the active tab with routing or other page state. Passing `null` as the value renders no active tab at all.

## Accessibility [#accessibility]

The list has `role="tablist"` and each trigger has `role="tab"` with `aria-selected` and `aria-controls` pointing at its panel, so screen readers announce position within the set.

Focus follows a roving model: the whole list is a single <kbd>Tab</kbd> stop, and <kbd>Tab</kbd> again moves into the active panel. Within the list:

* <kbd>ArrowLeft</kbd> and <kbd>ArrowRight</kbd> move between tabs in horizontal orientation; <kbd>ArrowUp</kbd> and <kbd>ArrowDown</kbd> in vertical. Directions mirror automatically in right-to-left layouts.
* <kbd>Home</kbd> and <kbd>End</kbd> jump to the first and last tab.
* Movement wraps from end to end by default; set `loopFocus={false}` on the list to stop at the edges.
* <kbd>Enter</kbd> or <kbd>Space</kbd> activates the focused tab under manual activation.

Disabled triggers remain focusable and announced but cannot be activated. Colors come from theme tokens, so active, hover, and disabled states adapt to dark mode automatically.

Trigger labels use `white-space: nowrap` with ellipsis overflow: long labels are silently cut off rather than wrapped. Keep labels to one or two words, or switch to vertical orientation where there is room to grow.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;tabs&#x22;]" />
  

  
    
      
        Copy and paste the following code into your project.
      

      ### components/ui/tabs.tsx

```tsx
"use client"

import { Tabs as TabsPrimitive } from "@base-ui-components/react/tabs"

import { cn } from "@/lib/utils"

type TabsSize = "small" | "medium" | "large" | "regular"
type TabsVariant = "default" | "underline" | "standalone" | "plain"

function Tabs({
  size = "large",
  className,
  ...props
}: TabsPrimitive.Root.Props & { size?: TabsSize }) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      className={cn(
        "flex w-full flex-col [--tabs-trigger-font-size:var(--hui-font-size-regular)] [--tabs-trigger-height:var(--hui-space-8)] [--tabs-trigger-letter-spacing:var(--hui-letter-spacing-regular)] [--tabs-trigger-line-height:var(--hui-line-height-regular)] [--tabs-trigger-padding-inline:var(--hui-space-3)] data-[orientation=vertical]:flex-row",
        size === "small" &&
          "[--tabs-trigger-font-size:var(--hui-font-size-mini)] [--tabs-trigger-height:var(--hui-space-6)] [--tabs-trigger-letter-spacing:var(--hui-letter-spacing-mini)] [--tabs-trigger-line-height:var(--hui-line-height-mini)] [--tabs-trigger-padding-inline:var(--hui-space-2)]",
        size === "medium" &&
          "[--tabs-trigger-font-size:var(--hui-font-size-small)] [--tabs-trigger-height:var(--hui-space-7)] [--tabs-trigger-letter-spacing:var(--hui-letter-spacing-small)] [--tabs-trigger-line-height:var(--hui-line-height-small)] [--tabs-trigger-padding-inline:var(--hui-space-3)]",
        className
      )}
      {...props}
    />
  )
}

function TabsList({
  variant = "default",
  indicatorClassName,
  className,
  children,
  ...props
}: TabsPrimitive.List.Props & {
  variant?: TabsVariant
  indicatorClassName?: string
}) {
  const resolvedVariant = variant === "underline" ? "plain" : variant

  return (
    <TabsPrimitive.List
      data-slot="tabs-list"
      className={cn(
        "relative flex w-full items-center gap-[var(--hui-space-2)] rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-neutral-secondary)] p-[var(--hui-space-1)] shadow-[var(--hui-shadow-inset)] data-[orientation=vertical]:w-fit data-[orientation=vertical]:flex-col",
        resolvedVariant === "standalone" &&
          "bg-transparent shadow-none [&>[data-slot=tabs-trigger]]:border-[0.5px] [&>[data-slot=tabs-trigger]]:border-[var(--hui-color-border-base-primary)] [&>[data-slot=tabs-trigger][data-active]]:border-transparent",
        resolvedVariant === "plain" &&
          "justify-center gap-[var(--hui-space-6)] rounded-none bg-transparent p-0 shadow-none [&>[data-slot=tabs-trigger]]:flex-none [&>[data-slot=tabs-trigger]]:rounded-none [&>[data-slot=tabs-trigger]]:border-b [&>[data-slot=tabs-trigger]]:border-b-transparent",
        className
      )}
      {...props}
    >
      {children}
      <TabsPrimitive.Indicator
        data-slot="tab-indicator"
        className={cn(
          "absolute top-0 left-0 z-0 h-[var(--active-tab-height,0)] w-[var(--active-tab-width,0)] rounded-[var(--hui-radius-2)] bg-[var(--hui-color-background-base-primary)] shadow-[var(--hui-shadow-feather)] [translate:var(--active-tab-left,0)_var(--active-tab-top,0)] motion-safe:transition-[translate,width,height] motion-safe:duration-[var(--hui-duration-moderate)] motion-safe:ease-[var(--hui-ease-in-out)]",
          resolvedVariant === "standalone" &&
            "box-border border-[0.5px] border-[var(--hui-color-border-base-secondary)] bg-[var(--hui-color-background-neutral-primary)] shadow-none",
          resolvedVariant === "plain" &&
            "rounded-none bg-[var(--hui-color-border-base-emphasis)] shadow-none data-[orientation=horizontal]:h-px data-[orientation=horizontal]:[translate:var(--active-tab-left,0px)_calc(var(--active-tab-top,0px)+var(--active-tab-height,0px)-1px)] data-[orientation=vertical]:w-px data-[orientation=vertical]:[translate:calc(var(--active-tab-left,0px)+var(--active-tab-width,0px)-1px)_var(--active-tab-top,0px)]",
          indicatorClassName
        )}
      />
    </TabsPrimitive.List>
  )
}

function TabsTab({ className, ...props }: TabsPrimitive.Tab.Props) {
  return (
    <TabsPrimitive.Tab
      data-slot="tabs-trigger"
      className={cn(
        "relative z-1 m-0 box-border inline-flex h-[var(--tabs-trigger-height)] flex-1 cursor-pointer items-center justify-center gap-[var(--hui-space-2)] overflow-hidden rounded-[var(--hui-radius-2)] border-0 bg-transparent px-[var(--tabs-trigger-padding-inline)] text-center text-ellipsis whitespace-nowrap text-[var(--hui-color-foreground-base-secondary)] outline-none [font-size:var(--tabs-trigger-font-size)] [font-style:normal] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--tabs-trigger-letter-spacing)] [line-height:var(--tabs-trigger-line-height)] transition-colors duration-[var(--hui-duration-normal)] ease-[var(--hui-ease-out)] data-active:text-[var(--hui-color-foreground-base-primary)] data-disabled:pointer-events-none data-disabled:opacity-50 [&:focus-visible]:[outline:var(--hui-focus-ring)] [&:hover:not([data-disabled])]:text-[var(--hui-color-foreground-base-primary)] [&:active:not([data-disabled]):not([data-active])]:scale-[var(--hui-scale-pressed)] motion-safe:transition-[color,transform] motion-safe:duration-[var(--hui-duration-normal),var(--hui-duration-press)] motion-safe:[transition-timing-function:var(--hui-ease-out)] [&_svg]:pointer-events-none [&_svg]:size-[var(--hui-space-5)] [&_svg]:shrink-0 [&_svg]:text-current",
        "data-[orientation=vertical]:w-full data-[orientation=vertical]:justify-start",
        className
      )}
      {...props}
    />
  )
}

function TabsPanel({ className, ...props }: TabsPrimitive.Panel.Props) {
  return (
    <TabsPrimitive.Panel
      data-slot="tabs-content"
      className={cn("outline-none", className)}
      {...props}
    />
  )
}

export {
  Tabs,
  TabsList,
  TabsTab,
  TabsTab as TabsTrigger,
  TabsPanel,
  TabsPanel as TabsContent
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/ui/tabs";
```

```tsx
<Tabs defaultValue="tab-1">
  
  Tab 1 content
  Tab 2 content
  Tab 3 content
</Tabs>
```

`TabsTrigger` and `TabsContent` are aliases of `TabsTab` and `TabsPanel`. Style panels independently — the component imposes no panel layout of its own.

## Don't do this [#dont-do-this]

### Using tabs for page navigation [#using-tabs-for-page-navigation]

```tsx
// Bad
<Tabs defaultValue="docs">
  
  ...
</Tabs>
```

```tsx
// Good
<nav aria-label="Main">
  [Docs](/docs)
  [Pricing](/pricing)
</nav>
```

Tabs switch in-view content; they do not navigate. A tabbed site header breaks browser Back, cannot be opened in a new tab, hides every page from direct linking, and announces itself as a tab set rather than navigation. Top-level destinations belong in links and nav landmarks. If sections genuinely share a URL-able route, sync the active tab to the router instead of relying on local state alone.

### Sequencing steps as tabs [#sequencing-steps-as-tabs]

```tsx
// Bad
<Tabs>
  
</Tabs>
```

```tsx
// Good
<ol>{/* Step 1, then step 2 with Next/Back */}</ol>
```

Tabs imply equal peers that can be visited in any order. Checkout steps have order, dependencies, and validation: jumping straight to Payment skips required Shipping data. Sequential flows need explicit progression with validation between steps.

### Overloading the tab bar [#overloading-the-tab-bar]

```tsx
// Bad
<TabsTab value="notifications-and-email-preferences">
  Notifications and email preferences
</TabsTab>
```

```tsx
// Good
<TabsTab value="notifications">Notifications</TabsTab>
```

Trigger text does not wrap; it truncates with an ellipsis once it outgrows the bar, so both sighted and screen-reader users lose the tail of the label. Keep each label short and unique, and split genuinely distinct concerns into more tabs rather than longer names.

## Examples [#examples]

### Settings sections [#settings-sections]

Two peer panels with independent, short labels — the canonical settings shape.

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/honest-ui/ui/tabs"

export function TabsSettings() {
  return (
    <Tabs defaultValue="profile" className="w-full max-w-sm">
      <TabsList><TabsTab value="profile">Profile</TabsTab><TabsTab value="security">Security</TabsTab></TabsList>
      <TabsPanel value="profile" className="rounded-xl border p-4 text-sm text-muted-foreground">Update your public workspace profile.</TabsPanel>
      <TabsPanel value="security" className="rounded-xl border p-4 text-sm text-muted-foreground">Manage sessions and two-factor settings.</TabsPanel>
    </Tabs>
  )
}

```

### Underline Variant [#underline-variant]

A quieter treatment for content pages: no filled background, just an animated underline marking the active tab.

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/honest-ui/ui/tabs"

export function TabsUnderline() {
  return (
    <Tabs defaultValue="tab-1">
      <div className="border-b">
        <TabsList variant="underline">
          <TabsTab value="tab-1">Tab 1</TabsTab>
          <TabsTab value="tab-2">Tab 2</TabsTab>
          <TabsTab value="tab-3">Tab 3</TabsTab>
        </TabsList>
      </div>
      <TabsPanel value="tab-1">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 1 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-2">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 2 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-3">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 3 content
        </p>
      </TabsPanel>
    </Tabs>
  )
}

```

### Vertical Orientation [#vertical-orientation]

Longer label lists read better down the side; arrow keys switch to Up and Down automatically.

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/honest-ui/ui/tabs"

export function TabsVertical() {
  return (
    <Tabs defaultValue="tab-1" orientation="vertical" className="w-full max-w-80">
      <TabsList>
        <TabsTab value="tab-1">Tab 1</TabsTab>
        <TabsTab value="tab-2">Tab 2</TabsTab>
        <TabsTab value="tab-3">Tab 3</TabsTab>
      </TabsList>
      <TabsPanel value="tab-1">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 1 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-2">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 2 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-3">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 3 content
        </p>
      </TabsPanel>
    </Tabs>
  )
}

```

### Underline with Vertical Orientation [#underline-with-vertical-orientation]

The underline variant composed with a vertical list.

```tsx
import { Tabs, TabsList, TabsPanel, TabsTab } from "@/components/honest-ui/ui/tabs"

export function TabsUnderlineVertical() {
  return (
    <Tabs
      defaultValue="tab-1"
      orientation="vertical"
      className="w-full max-w-80 flex-row"
    >
      <div className="border-s">
        <TabsList variant="underline">
          <TabsTab value="tab-1">Tab 1</TabsTab>
          <TabsTab value="tab-2">Tab 2</TabsTab>
          <TabsTab value="tab-3">Tab 3</TabsTab>
        </TabsList>
      </div>
      <TabsPanel value="tab-1">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 1 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-2">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 2 content
        </p>
      </TabsPanel>
      <TabsPanel value="tab-3">
        <p className="p-4 text-center text-xs text-muted-foreground">
          Tab 3 content
        </p>
      </TabsPanel>
    </Tabs>
  )
}

```

## API reference [#api-reference]

`Tabs`, `TabsList`, `TabsTab`, and `TabsPanel` forward their matching Base UI props. Root props include `value`, `defaultValue`, `onValueChange`, and `orientation` (`"horizontal"` default, `"vertical"`); passing `value={null}` leaves no tab active.

Honest UI additions:

| Prop                 | Component  | Values                                                | Default     |
| -------------------- | ---------- | ----------------------------------------------------- | ----------- |
| `size`               | `Tabs`     | `"small"`, `"medium"`, `"large"`, `"regular"`         | `"large"`   |
| `variant`            | `TabsList` | `"default"`, `"underline"`, `"standalone"`, `"plain"` | `"default"` |
| `indicatorClassName` | `TabsList` | string                                                | —           |

`small` and `medium` reduce trigger height, font size, and letter spacing for dense surfaces; `large` and `regular` currently share the same base metrics. `variant="underline"` renders a borderless list with a sliding bottom indicator, `standalone` draws individual bordered chips with a raised active card, and `plain` is the underline style used internally. Set `activateOnFocus` and `loopFocus` on `TabsList` to change keyboard behavior.

See the [Base UI Tabs API](https://base-ui.com/react/components/tabs#api-reference).
