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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled active tab value. |
defaultValue | string | — | 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. |
...props | React.ComponentProps<'div'> | — | Radix Tabs.Root props. The root is a flex column with gap-2. |
TabsList Props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'default' | 'pills' | 'default' | Visual style of the tab list: an underlined strip, or a padded muted track. |
loop | boolean | true | Radix roving focus: whether arrow keys wrap around at the ends. |
...props | React.ComponentProps<'div'> | — | Radix Tabs.List props; rendered with role="tablist". |
TabsTrigger Props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | Unique identifier for this tab; matches a TabsContent value. |
variant | 'default' | 'pills' | 'default' | Must match the TabsList variant — the two levels share no context. |
disabled | boolean | false | Skips the tab in roving focus and drops it to 50% opacity. |
...props | React.ComponentProps<'button'> | — | Radix Tabs.Trigger props; rendered with role="tab". |
TabsContent Props
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | — | The trigger value this panel belongs to. |
forceMount | true | — | Keeps the panel mounted while hidden — for animation libraries that need the node to persist. |
...props | React.ComponentProps<'div'> | — | Radix Tabs.Content props; rendered with role="tabpanel", plus mt-4 flex-1 outline-none. |
RouteTabs Props
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | RouteTabItem[] | — | Each item is { href, label, match? }. match="prefix" also lights up on child routes; the default is an exact pathname comparison. |
pathnamerequired | string | — | 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. |
LinkComponent | ComponentType<RouteTabsLinkProps> | <a> | Your app’s link component, e.g. next/link. Left out, items render as plain anchors and reload the page. |
label | string | 'Mục của trang' | aria-label for the <nav>. Pass an English string. |
className | string | — | Extra classes for the list strip. |
itemClassName | string | — | Extra classes for every link. |
Accessibility
- Uses
role="tablist",role="tab", androle="tabpanel". - Active tab has
aria-selected="true"andtabindex="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_BASEclasses. - 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 usesaria-current="page"on real links instead.