Trang này chưa có bản tiếng Việt. Nội dung dưới đây là bản tiếng Anh.

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

PropKiểuMặc địnhMô tả
valuestring—Controlled active tab value.
defaultValuestring—Initial active tab for uncontrolled usage.
onValueChange(value: string) => void—Callback 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

PropKiểuMặc địnhMô tả
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

PropKiểuMặc địnhMô tả
valuebắt buộcstring—Unique 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

PropKiểuMặc địnhMô tả
valuebắt buộcstring—The trigger value this panel belongs to.
forceMounttrue—Keeps 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

PropKiểuMặc địnhMô tả
itemsbắt buộcRouteTabItem[]—Each item is { href, label, match? }. match="prefix" also lights up on child routes; the default is an exact pathname comparison.
pathnamebắt buộcstring—The 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'Page sections'aria-label for the <nav>. Follows ComitorLocaleProvider (“Mục của trang” under locale="vi") — the one part of RouteTabs that reads it, through a small internal client <nav>, so the component itself stays server-safe and a layout can still hand it LinkComponent. Name the set when you can, e.g. 'Settings sections'.
classNamestring—Extra classes for the list strip.
itemClassNamestring—Extra 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.