Components

Tabs

Organize content into tabbed panels. Supports controlled and uncontrolled modes, with two visual variants.

These are stateful tabs: Radix remembers which one is open and the panels live on the same page. When each tab is really its own route, reach for RouteTabs instead — identical looks, but a <nav> of real links. See Route-level tabs below.

Basic usage

Use defaultValue for uncontrolled behavior.

Manage your account settings and preferences.

Pills variant

Use variant="pills" on both TabsList and TabsTrigger for a segmented control style. The two levels share no React context, so the prop really does have to be repeated — that is the design, kept for compatibility.

Weekly statistics and metrics.

Rule of thumb from the package: default is for page-level tabs, pills for tabs inside a panel or card.

Compound API

Tabs carries its parts as properties as well as exporting them individually, so Tabs.List, Tabs.Trigger and Tabs.Content are the same components under a shorter import. Both spellings are supported; pick one per file.

Overview panel.

Route-level tabs

When every tab is a URL of its own, Tabs is the wrong tool: it builds role="tablist" and aria-controls pointing at a tabpanel on the same page — with no panel to point at, that becomes a dangling id, role="tab" promises a swap that is really a navigation, and the tablist's roving focus swallows arrow keys a list of links should leave alone. RouteTabs renders a <nav> of real links and marks the current one with aria-current="page". It reads the same class table, so it is visually indistinguishable.

Two things to watch. LinkComponent is required for client-side routing — the package never imports next/link itself so it still runs outside Next, and without it every tab reloads the page. And match: 'prefix' on the group's root href lights that item up on every sibling route; leave the root item on the default exact match.

Shared class recipes

The two variants are published as data, not just baked into the components: tabsListVariants and tabsTriggerVariants are Record<TabsVariant, string> lookups, and TABS_TRIGGER_BASE holds the focus ring and disabled state shared by both. They live in a module with no 'use client' directive on purpose: Tabs is a client component and RouteTabs is not, and a plain data module is the only way both can read one copy instead of drifting apart.

custom-tab-strip.tsx
import { TABS_TRIGGER_BASE, cn, tabsListVariants, tabsTriggerVariants } from '@comitor/ui'

// The two class recipes live in a plain module with no 'use client', so a
// Server Component can read them. Both Tabs and RouteTabs consume this same
// table — change a tab's look in one place and both follow.
export function CustomTabStrip({ active }: { active: string }) {
  return (
    <div className={cn('flex gap-0', tabsListVariants.pills)}>
      {['Daily', 'Weekly'].map((item) => (
        <button
          key={item}
          type="button"
          data-state={item === active ? 'active' : 'inactive'}
          className={cn(tabsTriggerVariants.pills, TABS_TRIGGER_BASE)}
        >
          {item}
        </button>
      ))}
    </div>
  )
}

The triggers key off data-[state=active], which is why anything you build with these classes only has to set data-state to inherit the whole look.

How the active tab is marked

In the default variant the label only shifts from muted-foreground to foreground, so the 2px underline is the one element carrying colour information. It is drawn with border-primary-ink rather than a hard-coded border-gold-300, so an app that rebrands the accent gets a matching underline and the package's second palette can restyle it without touching the component.

Worth knowing what that token resolves to. In the default palette --primary-ink points straight at --primary (#E8B824 — the same gold as gold-300), which is 1.86:1 against the page, under the 3:1 WCAG 1.4.11 asks of a non-text cue. The package names this exact case — “the Tabs active underline” — in the list of shortfalls its approved palette knowingly accepts, and the remedy it ships is the second palette: under data-contrast="high" the token becomes gold-500 (#7A5800) at 6.51:1. Dark sits at 12.67:1 in both. So do not treat the underline as the whole answer in light — aria-selected is what carries the state for assistive tech.

The border is always 2px, transparent when inactive and -mb-px to sit on the list's own bottom rule, so switching tabs never nudges the layout.

Tabs Props

PropTypeDefaultDescription
valuestringControlled active tab value.
defaultValuestringInitial active tab for uncontrolled usage.
onValueChange(value: string) => voidCallback when active tab changes.
orientation'horizontal' | 'vertical''horizontal'Which arrow keys move between tabs. Layout classes are yours to supply.
activationMode'automatic' | 'manual''automatic'Automatic selects a tab as focus reaches it; manual waits for Enter or Space.
dir'ltr' | 'rtl'Reading direction, which flips the arrow-key mapping.
...propsReact.ComponentProps<'div'>Radix Tabs.Root props. The root is a flex column with gap-2.

TabsList Props

PropTypeDefaultDescription
variant'default' | 'pills''default'Visual style of the tab list: an underlined strip, or a padded muted track.
loopbooleantrueRadix roving focus: whether arrow keys wrap around at the ends.
...propsReact.ComponentProps<'div'>Radix Tabs.List props; rendered with role="tablist".

TabsTrigger Props

PropTypeDefaultDescription
valuerequiredstringUnique identifier for this tab; matches a TabsContent value.
variant'default' | 'pills''default'Must match the TabsList variant — the two levels share no context.
disabledbooleanfalseSkips the tab in roving focus and drops it to 50% opacity.
...propsReact.ComponentProps<'button'>Radix Tabs.Trigger props; rendered with role="tab".

TabsContent Props

PropTypeDefaultDescription
valuerequiredstringThe trigger value this panel belongs to.
forceMounttrueKeeps the panel mounted while hidden — for animation libraries that need the node to persist.
...propsReact.ComponentProps<'div'>Radix Tabs.Content props; rendered with role="tabpanel", plus mt-4 flex-1 outline-none.

RouteTabs Props

PropTypeDefaultDescription
itemsrequiredRouteTabItem[]Each item is { href, label, match? }. match="prefix" also lights up on child routes; the default is an exact pathname comparison.
pathnamerequiredstringThe current path — usePathname() on Next. RouteTabs derives the active item from it rather than holding state.
variant'default' | 'pills''default'Same two looks as Tabs; both read the same class table.
LinkComponentComponentType<RouteTabsLinkProps><a>Your app’s link component, e.g. next/link. Left out, items render as plain anchors and reload the page.
labelstring'Mục của trang'aria-label for the <nav>. Pass an English string.
classNamestringExtra classes for the list strip.
itemClassNamestringExtra classes for every link.

Accessibility

  • Uses role="tablist", role="tab", and role="tabpanel".
  • Active tab has aria-selected="true" and tabindex="0".
  • Inactive tabs have tabindex="-1"; arrow keys move between them and Tab moves out of the strip.
  • Tabpanel is focusable for keyboard users. The panel's own outline is cleared — the visible focus ring belongs to the triggers, via the shared TABS_TRIGGER_BASE classes.
  • With activationMode="manual", arrowing through tabs moves focus without switching panels — worth it when a panel is expensive to load.
  • Never use these roles for navigation. A tab strip whose items are routes belongs in RouteTabs, which uses aria-current="page" on real links instead.