Skip to main content

Tabs

  • Component overview: Tabs organize content into panels and switch between them. The public API is declarative: children are treated as triggers in simple cases, and panel content is configured through items.
  • Size baseline: Container height=40px, tab gap=24px (Default/Underline) or 16px (Chevron).
  • Implementation note: ui/tabs retains only the structural skeleton; the design layer owns all visual tokens. The variant prop is configured directly on Tabs.
  • Figma spec

Basic Usage

Result
Loading...
Live Editor
<Tabs
  defaultValue="tab1"
  items={[
    {
      value: 'tab1',
      label: 'Account',
      content: (
        <p style={{ padding: '16px 0', color: '#333' }}>
          Manage your account settings and preferences.
        </p>
      ),
    },
    {
      value: 'tab2',
      label: 'Settings',
      content: (
        <p style={{ padding: '16px 0', color: '#333' }}>Customize your application settings.</p>
      ),
    },
    {
      value: 'tab3',
      label: 'Notifications',
      content: (
        <p style={{ padding: '16px 0', color: '#333' }}>Configure your notification preferences.</p>
      ),
    },
  ]}
/>

Variants

3 visual variants to fit different contexts.

Default

Plain text tabs, minimal visual style.

Result
Loading...
Live Editor
<Tabs
  defaultValue="tab1"
  variant="default"
  items={[
    { value: 'tab1', label: 'Tab 1', content: <p style={{ padding: '16px 0' }}>Content 1</p> },
    { value: 'tab2', label: 'Tab 2', content: <p style={{ padding: '16px 0' }}>Content 2</p> },
    { value: 'tab3', label: 'Tab 3', content: <p style={{ padding: '16px 0' }}>Content 3</p> },
  ]}
/>

Underline

Active tab has a 2px bottom border indicator.

Result
Loading...
Live Editor
<Tabs
  defaultValue="tab1"
  variant="underline"
  items={[
    { value: 'tab1', label: 'Tab 1', content: <p style={{ padding: '16px 0' }}>Content 1</p> },
    { value: 'tab2', label: 'Tab 2', content: <p style={{ padding: '16px 0' }}>Content 2</p> },
    { value: 'tab3', label: 'Tab 3', content: <p style={{ padding: '16px 0' }}>Content 3</p> },
  ]}
/>

Chevron

Active tab shows a chevron down icon, hinting at expandable content.

Result
Loading...
Live Editor
<Tabs
  defaultValue="tab1"
  variant="chevron"
  items={[
    { value: 'tab1', label: 'Tab 1', content: <p style={{ padding: '16px 0' }}>Content 1</p> },
    { value: 'tab2', label: 'Tab 2', content: <p style={{ padding: '16px 0' }}>Content 2</p> },
    { value: 'tab3', label: 'Tab 3', content: <p style={{ padding: '16px 0' }}>Content 3</p> },
  ]}
/>

Variant Comparison

All three variants side by side.

Result
Loading...
Live Editor
<div style={{ display: 'flex', flexDirection: 'column', gap: 32 }}>
  <div>
    <p style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>Default</p>
    <Tabs
      defaultValue="tab1"
      variant="default"
      items={[
        { value: 'tab1', label: 'Tab 1' },
        { value: 'tab2', label: 'Tab 2' },
      ]}
    />
  </div>
  <div>
    <p style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>Underline</p>
    <Tabs
      defaultValue="tab1"
      variant="underline"
      items={[
        { value: 'tab1', label: 'Tab 1' },
        { value: 'tab2', label: 'Tab 2' },
      ]}
    />
  </div>
  <div>
    <p style={{ fontSize: 12, color: '#999', marginBottom: 8 }}>Chevron</p>
    <Tabs
      defaultValue="tab1"
      variant="chevron"
      items={[
        { value: 'tab1', label: 'Tab 1' },
        { value: 'tab2', label: 'Tab 2' },
      ]}
    />
  </div>
</div>

Disabled State

Individual tabs can be disabled.

Result
Loading...
Live Editor
<Tabs
  defaultValue="tab1"
  variant="underline"
  items={[
    {
      value: 'tab1',
      label: 'Active',
      content: <p style={{ padding: '16px 0' }}>Active content</p>,
    },
    {
      value: 'tab2',
      label: 'Disabled',
      disabled: true,
      content: <p style={{ padding: '16px 0' }}>Disabled content</p>,
    },
    {
      value: 'tab3',
      label: 'Normal',
      content: <p style={{ padding: '16px 0' }}>Normal content</p>,
    },
  ]}
/>

Scrollable Overflow

When there are many tabs, the list automatically becomes horizontally scrollable with left/right navigation arrows.

Result
Loading...
Live Editor
<Tabs
  defaultValue="t1"
  variant="underline"
  items={[
    {
      value: 't1',
      label: 'Dashboard',
      content: <p style={{ padding: '16px 0' }}>Dashboard content</p>,
    },
    { value: 't2', label: 'Analytics' },
    { value: 't3', label: 'Reports' },
    { value: 't4', label: 'Notifications' },
    { value: 't5', label: 'Settings' },
    { value: 't6', label: 'Integrations' },
    { value: 't7', label: 'Billing' },
    { value: 't8', label: 'Security' },
    { value: 't9', label: 'Team' },
    { value: 't10', label: 'API Keys' },
  ]}
/>

Preserve State (destroyOnHidden)

By default, inactive tab panels are unmounted. Set destroyOnHidden={false} to keep them in the DOM, preserving internal state like scroll position or form input.

Result
Loading...
Live Editor
const PreserveStateExample = () => {
  const [count, setCount] = useState(0)
  return (
    <Tabs
      defaultValue="tab1"
      items={[
        {
          value: 'tab1',
          label: 'Counter',
          destroyOnHidden: false,
          content: (
            <div style={{ padding: '16px 0' }}>
              <p>Count: {count}</p>
              <button
                onClick={() => setCount((current) => current + 1)}
                style={{
                  marginTop: 8,
                  padding: '4px 12px',
                  border: '1px solid #ccc',
                  borderRadius: 4,
                }}
              >
                Increment
              </button>
              <p style={{ fontSize: 12, color: '#999', marginTop: 8 }}>
                Switch tabs — the count is preserved.
              </p>
            </div>
          ),
        },
        {
          value: 'tab2',
          label: 'Other',
          destroyOnHidden: false,
          content: (
            <p style={{ padding: '16px 0' }}>
              Other content. Go back to see the count is still there.
            </p>
          ),
        },
      ]}
    />
  )
}

render(<PreserveStateExample />)

Size Spec

DimensionValue
Container height40px
Tab gap (Default)24px
Tab gap (Underline)24px
Tab gap (Chevron)16px
Font size14px
Line height22px
Active font weight500 (medium)
Inactive font weight400 (normal)
Underline thickness2px
Chevron icon size20×20px

Props

Tabs

PropTypeDefaultDescription
itemsTabsItem[]-Declarative tab items
variant'default' | 'underline' | 'chevron''default'Visual variant
defaultValuestring-Default active tab (uncontrolled)
valuestring-Active tab (controlled)
onValueChange(value: string) => void-Callback when active tab changes
classNamestring-Custom wrapper class name
listClassNamestring-Custom class for the tab list

TabsItem

FieldTypeDefaultDescription
valuestring-Unique value identifying the tab
labelReactNode-Trigger content
contentReactNode-Panel content
disabledbooleanfalseDisables the tab trigger
triggerClassNamestring-Custom class applied to the trigger
contentClassNamestring-Custom class applied to the panel
destroyOnHiddenbooleantrueKeep inactive panel mounted when false