# Tabs

> Switch between views with an indicator that slides to the active tab, as a segmented control or an underline, and lists that scroll when they overflow.

Docs: https://hextaui.com/docs/tabs
Markdown: https://hextaui.com/docs/tabs.md

```tsx title="components/examples/tabs/demo.tsx"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

export function TabsDemo() {
  return (
    <Tabs defaultValue="overview" className="w-full max-w-md">
      <TabsList>
        <TabsTrigger value="overview">Overview</TabsTrigger>
        <TabsTrigger value="analytics">Analytics</TabsTrigger>
        <TabsTrigger value="reports">Reports</TabsTrigger>
      </TabsList>
      <TabsContent value="overview">
        <div className="rounded-xl border p-4 text-sm">
          <p className="font-medium">Overview</p>
          <p className="text-muted-foreground">
            Visitors are up 12% this week, mostly from search.
          </p>
        </div>
      </TabsContent>
      <TabsContent value="analytics">
        <div className="rounded-xl border p-4 text-sm">
          <p className="font-medium">Analytics</p>
          <p className="text-muted-foreground">
            Average session length is 4m 12s across 18,240 visits.
          </p>
        </div>
      </TabsContent>
      <TabsContent value="reports">
        <div className="rounded-xl border p-4 text-sm">
          <p className="font-medium">Reports</p>
          <p className="text-muted-foreground">
            Three scheduled reports go out on Monday.
          </p>
        </div>
      </TabsContent>
    </Tabs>
  )
}
```

## Installation

### CLI

```bash
npx shadcn@latest add https://hextaui.com/r/tabs.json
```

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react class-variance-authority cn
```

Copy and paste the following code into your project.

```tsx title="components/ui/tabs.tsx"
"use client"

import * as React from "react"
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
import { cva } from "class-variance-authority"
import { cn } from "cn"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type TabsVariant = "default" | "line"

const TabsListContext = React.createContext<TabsVariant>("default")

function Tabs({ className, ...props }: TabsPrimitive.Root.Props) {
  return (
    <TabsPrimitive.Root
      data-slot="tabs"
      className={mergeClassName(
        "group/tabs flex min-w-0 gap-3 data-[orientation=horizontal]:flex-col",
        className
      )}
      {...props}
    />
  )
}

function useScrollEdges(list: HTMLElement | null) {
  React.useEffect(() => {
    if (!list) {
      return
    }
    let frame = 0
    const update = () => {
      cancelAnimationFrame(frame)
      frame = requestAnimationFrame(() => {
        const vertical = list.getAttribute("data-orientation") === "vertical"
        const offset = vertical ? list.scrollTop : Math.abs(list.scrollLeft)
        const max = vertical
          ? list.scrollHeight - list.clientHeight
          : list.scrollWidth - list.clientWidth
        list.toggleAttribute("data-scrolled-start", offset > 0.5)
        list.toggleAttribute("data-scrolled-end", offset < max - 0.5)
      })
    }
    update()
    list.addEventListener("scroll", update, { passive: true })
    const observer =
      typeof ResizeObserver === "undefined" ? null : new ResizeObserver(update)
    observer?.observe(list)
    return () => {
      cancelAnimationFrame(frame)
      list.removeEventListener("scroll", update)
      observer?.disconnect()
    }
  }, [list])
}

function useRevealActive(list: HTMLElement | null) {
  React.useEffect(() => {
    if (!list || typeof MutationObserver === "undefined") {
      return
    }
    const reveal = () => {
      const tab = list.querySelector<HTMLElement>(
        "[data-slot=tabs-trigger][data-active]"
      )
      if (!tab || list.scrollWidth <= list.clientWidth + 1) {
        return
      }
      const box = list.getBoundingClientRect()
      const rect = tab.getBoundingClientRect()
      const margin = 24
      if (rect.left < box.left + margin) {
        list.scrollBy({
          left: rect.left - box.left - margin,
          behavior: "smooth",
        })
      } else if (rect.right > box.right - margin) {
        list.scrollBy({
          left: rect.right - box.right + margin,
          behavior: "smooth",
        })
      }
    }
    const observer = new MutationObserver(reveal)
    observer.observe(list, {
      subtree: true,
      attributes: true,
      attributeFilter: ["data-active"],
    })
    const onFocus = (event: FocusEvent) => {
      const tab = (event.target as Element).closest<HTMLElement>(
        "[data-slot=tabs-trigger]"
      )
      if (!tab || list.scrollWidth <= list.clientWidth + 1) {
        return
      }
      const box = list.getBoundingClientRect()
      const rect = tab.getBoundingClientRect()
      if (rect.left < box.left || rect.right > box.right) {
        list.scrollBy({
          left:
            rect.left < box.left
              ? rect.left - box.left - 24
              : rect.right - box.right + 24,
          behavior: "smooth",
        })
      }
    }
    list.addEventListener("focusin", onFocus)
    return () => {
      observer.disconnect()
      list.removeEventListener("focusin", onFocus)
    }
  }, [list])
}

const tabsListVariants = cva(
  "group/tabs-list relative isolate flex max-w-full min-w-0 [scrollbar-width:none] overflow-x-auto overscroll-x-none data-[orientation=vertical]:flex-col data-[orientation=vertical]:overflow-x-visible data-[orientation=vertical]:overflow-y-auto [&::-webkit-scrollbar]:hidden",
  {
    variants: {
      variant: {
        default:
          "w-fit items-center gap-0.5 rounded-lg bg-muted p-[3px] data-[orientation=vertical]:items-stretch",
        line: "w-full gap-1 shadow-[inset_0_calc(var(--hairline)*-1)_0_var(--border)] data-[orientation=vertical]:w-fit data-[orientation=vertical]:shadow-[inset_calc(var(--hairline)*-1)_0_0_var(--border)] rtl:data-[orientation=vertical]:shadow-[inset_var(--hairline)_0_0_var(--border)]",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

type TabsListProps = TabsPrimitive.List.Props & {
  variant?: TabsVariant
}

function TabsList({
  className,
  variant = "default",
  children,
  ref,
  ...props
}: TabsListProps) {
  const [list, setList] = React.useState<HTMLElement | null>(null)
  useScrollEdges(list)
  useRevealActive(list)

  React.useEffect(() => {
    if (!list) {
      return
    }
    let second = 0
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() => list.setAttribute("data-ready", ""))
    })
    return () => {
      cancelAnimationFrame(first)
      cancelAnimationFrame(second)
    }
  }, [list])

  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      setList(node)
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  return (
    <TabsListContext.Provider value={variant}>
      <TabsPrimitive.List
        ref={setRef}
        data-slot="tabs-list"
        data-variant={variant}
        className={mergeClassName(
          cn(
            tabsListVariants({ variant }),
            "[--fade-end:0px] [--fade-start:0px] data-scrolled-end:[--fade-end:--spacing(6)] data-scrolled-start:[--fade-start:--spacing(6)] data-[orientation=horizontal]:mask-[linear-gradient(to_right,transparent,#000_var(--fade-start),#000_calc(100%-var(--fade-end)),transparent)] rtl:data-[orientation=horizontal]:mask-[linear-gradient(to_left,transparent,#000_var(--fade-start),#000_calc(100%-var(--fade-end)),transparent)]"
          ),
          className
        )}
        {...props}
      >
        {children}
        <TabsPrimitive.Indicator
          renderBeforeHydration
          data-slot="tabs-indicator"
          className={cn(
            "pointer-events-none absolute top-0 left-0 -z-1 h-(--active-tab-height) w-(--active-tab-width) translate-x-(--active-tab-left) translate-y-(--active-tab-top) group-data-ready/tabs-list:ease-out-quint motion-safe:group-data-ready/tabs-list:transition-[translate,width,height] motion-safe:group-data-ready/tabs-list:duration-300 motion-reduce:transition-none forced-colors:border",
            variant === "default"
              ? "rounded-md bg-background ring-(length:--hairline) ring-foreground/5 dark:bg-input/50 forced-colors:border"
              : "bg-foreground group-data-[orientation=horizontal]/tabs-list:top-auto group-data-[orientation=horizontal]/tabs-list:bottom-0 group-data-[orientation=horizontal]/tabs-list:h-0.5 group-data-[orientation=horizontal]/tabs-list:translate-y-0 group-data-[orientation=vertical]/tabs-list:w-0.5 group-data-[orientation=vertical]/tabs-list:translate-x-[calc(var(--active-tab-left)+var(--active-tab-width)-2px)] rtl:group-data-[orientation=vertical]/tabs-list:translate-x-(--active-tab-left)"
          )}
        />
      </TabsPrimitive.List>
    </TabsListContext.Provider>
  )
}

function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
  const variant = React.useContext(TabsListContext)

  return (
    <TabsPrimitive.Tab
      data-slot="tabs-trigger"
      className={mergeClassName(
        cn(
          "relative inline-flex shrink-0 items-center justify-center gap-1.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-colors duration-150 outline-none select-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden data-disabled:pointer-events-none data-disabled:opacity-50 data-active:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [@media(hover:hover)]:hover:text-foreground",
          variant === "default"
            ? "h-8 flex-1 rounded-md px-3 group-data-[orientation=vertical]/tabs-list:justify-start pointer-coarse:h-10"
            : "h-10 px-2 group-data-[orientation=vertical]/tabs-list:h-9 group-data-[orientation=vertical]/tabs-list:justify-start group-data-[orientation=vertical]/tabs-list:pe-4 focus-visible:rounded-md focus-visible:ring-inset pointer-coarse:h-11"
        ),
        className
      )}
      {...props}
    />
  )
}

function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
  return (
    <TabsPrimitive.Panel
      data-slot="tabs-content"
      className={mergeClassName(
        "min-w-0 flex-1 rounded-md outline-none focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:outline-hidden",
        className
      )}
      {...props}
    />
  )
}

export { Tabs, TabsContent, TabsList, TabsTrigger, tabsListVariants }
export type { TabsListProps, TabsVariant }
```

Update the import paths to match your project setup.

## Usage

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

```tsx
<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="analytics">Analytics</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">…</TabsContent>
  <TabsContent value="analytics">…</TabsContent>
</Tabs>
```

One indicator slides to the active tab and resizes to fit it, while the content switches instantly so frequent switching never waits on an animation. The indicator is placed before the page hydrates, so it never jumps in on load.

## Composition

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

## Examples

### Underline

`variant="line"` on `TabsList` slides an underline instead of a pill.

```tsx title="components/examples/tabs/line.tsx"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

const tabs = ["Activity", "Pull requests", "Issues", "Settings"]

export function TabsLine() {
  return (
    <Tabs defaultValue="Activity" className="w-full max-w-md">
      <TabsList variant="line">
        {tabs.map((tab) => (
          <TabsTrigger key={tab} value={tab}>
            {tab}
          </TabsTrigger>
        ))}
      </TabsList>
      {tabs.map((tab) => (
        <TabsContent key={tab} value={tab}>
          <p className="py-2 text-sm text-muted-foreground">
            {tab} for this repository.
          </p>
        </TabsContent>
      ))}
    </Tabs>
  )
}
```

### Many tabs

When tabs don't fit, the list scrolls sideways, fades the edges with more to see, and scrolls the active or focused tab into view.

```tsx title="components/examples/tabs/overflow.tsx"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"

const months = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

export function TabsOverflow() {
  return (
    <Tabs defaultValue="September" className="w-full max-w-sm">
      <TabsList variant="line">
        {months.map((month) => (
          <TabsTrigger key={month} value={month}>
            {month}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  )
}
```

### Many tabs, segmented

The pill style scrolls the same way, keeping its track while the picked tab stays in view.

```tsx title="components/examples/tabs/overflow-segmented.tsx"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"

const categories = [
  "All",
  "Design",
  "Engineering",
  "Marketing",
  "Sales",
  "Support",
  "Finance",
  "Legal",
  "Operations",
]

export function TabsOverflowSegmented() {
  return (
    <Tabs defaultValue="Finance" className="w-full max-w-xs">
      <TabsList>
        {categories.map((category) => (
          <TabsTrigger key={category} value={category}>
            {category}
          </TabsTrigger>
        ))}
      </TabsList>
    </Tabs>
  )
}
```

### Icons and counts

Put icons and badges inside triggers.

```tsx title="components/examples/tabs/icons.tsx"
import { IconCode, IconGitPullRequest, IconMessage } from "@tabler/icons-react"

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

export function TabsIcons() {
  return (
    <Tabs defaultValue="code">
      <TabsList variant="line">
        <TabsTrigger value="code">
          <IconCode />
          Code
        </TabsTrigger>
        <TabsTrigger value="pulls">
          <IconGitPullRequest />
          Pull requests
          <Badge>12</Badge>
        </TabsTrigger>
        <TabsTrigger value="discussions">
          <IconMessage />
          Discussions
        </TabsTrigger>
      </TabsList>
    </Tabs>
  )
}
```

### Vertical

`orientation="vertical"` stacks the tabs; the underline moves to the side.

```tsx title="components/examples/tabs/vertical.tsx"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

const sections = [
  { value: "general", label: "General", text: "Name, avatar and language." },
  { value: "security", label: "Security", text: "Password and two-factor." },
  { value: "billing", label: "Billing", text: "Plan, invoices and payment." },
]

export function TabsVertical() {
  return (
    <Tabs
      defaultValue="general"
      orientation="vertical"
      className="w-full max-w-md"
    >
      <TabsList variant="line">
        {sections.map((section) => (
          <TabsTrigger key={section.value} value={section.value}>
            {section.label}
          </TabsTrigger>
        ))}
      </TabsList>
      {sections.map((section) => (
        <TabsContent key={section.value} value={section.value}>
          <div className="flex flex-col gap-1 py-2 text-sm">
            <p className="font-medium">{section.label}</p>
            <p className="text-muted-foreground">{section.text}</p>
          </div>
        </TabsContent>
      ))}
    </Tabs>
  )
}
```

### Disabled

Disabled tabs are skipped by the keyboard.

```tsx title="components/examples/tabs/disabled.tsx"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"

export function TabsDisabled() {
  return (
    <Tabs defaultValue="day">
      <TabsList>
        <TabsTrigger value="day">Day</TabsTrigger>
        <TabsTrigger value="week">Week</TabsTrigger>
        <TabsTrigger value="month" disabled>
          Month
        </TabsTrigger>
      </TabsList>
    </Tabs>
  )
}
```

### Controlled

Pass `value` and `onValueChange`. Changes from outside slide the indicator too.

```tsx title="components/examples/tabs/controlled.tsx"
"use client"

import * as React from "react"

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

const steps = ["Cart", "Shipping", "Payment", "Review"]

export function TabsControlled() {
  const [step, setStep] = React.useState("Cart")
  const index = steps.indexOf(step)

  return (
    <div className="flex flex-col items-start gap-4">
      <Tabs value={step} onValueChange={(value) => setStep(String(value))}>
        <TabsList>
          {steps.map((item) => (
            <TabsTrigger key={item} value={item}>
              {item}
            </TabsTrigger>
          ))}
        </TabsList>
      </Tabs>
      <Button
        size="sm"
        variant="outline"
        onClick={() => setStep(steps[(index + 1) % steps.length])}
      >
        Next step
      </Button>
    </div>
  )
}
```

### Right to left

Tabs, arrow keys and the indicator follow the reading direction.

```tsx title="components/examples/tabs/rtl.tsx"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

export function TabsRtl() {
  return (
    <div dir="rtl" className="w-full max-w-sm">
      <Tabs defaultValue="account">
        <TabsList variant="line">
          <TabsTrigger value="account">الحساب</TabsTrigger>
          <TabsTrigger value="password">كلمة المرور</TabsTrigger>
          <TabsTrigger value="alerts">التنبيهات</TabsTrigger>
        </TabsList>
        <TabsContent value="account">
          <p className="py-2 text-sm text-muted-foreground">إعدادات الحساب.</p>
        </TabsContent>
        <TabsContent value="password">
          <p className="py-2 text-sm text-muted-foreground">
            غيّر كلمة المرور.
          </p>
        </TabsContent>
        <TabsContent value="alerts">
          <p className="py-2 text-sm text-muted-foreground">إدارة التنبيهات.</p>
        </TabsContent>
      </Tabs>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves into the list on the active tab, then into the panel. |
| `←` `→` | Moves between tabs, wrapping at the ends. ↑ and ↓ when vertical. |
| `Home` `End` | Moves to the first or last tab. |
| `Enter` `Space` | Opens the focused tab. Set activateOnFocus on TabsList to open tabs as focus moves. |

## Accessibility

- The list is a `tablist`; each trigger is a `tab` that controls its `tabpanel`.
- Panels are focusable, so keyboard users can reach content that has no links or buttons.
- With reduced motion, the indicator jumps instead of sliding.

## API reference

### Tabs

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `any` | – |  |
| `defaultValue` | `any` | `0` |  |
| `onValueChange` | `(value, details) => void` | – |  |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |

| Attribute | Description |
| --- | --- |
| `data-activation-direction` | "left", "right", "up" or "down": where the new tab is relative to the old one. |

### TabsList

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

| Attribute | Description |
| --- | --- |
| `data-slot="tabs-indicator"` | The sliding pill or underline. |
| `data-scrolled-start / data-scrolled-end` | Present when the list can scroll that way. |
| `--active-tab-left / --active-tab-width` | Position and size of the active tab, also -top and -height. |

### TabsTrigger

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `any` | – |  |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-active` | The open tab. |
| `data-disabled` | The tab is disabled. |

### TabsContent

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `any` | – |  |
| `keepMounted` | `boolean` | `false` | Keep the panel in the DOM while hidden. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
