Components · Navigation
Tabs
Line and pill tab sets, link tabs, filter tabs, counts and scrolling lists.
Line and pill
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@rookcom/monarch-react';
<Tabs defaultValue="board">
<TabsList aria-label="Views">
<TabsTrigger value="board">Board</TabsTrigger>
<TabsTrigger value="list">List</TabsTrigger>
<TabsTrigger value="timeline" disabled>
Timeline
</TabsTrigger>
</TabsList>
<TabsContent value="board">Board view</TabsContent>
<TabsContent value="list">List view</TabsContent>
</Tabs>
<Tabs defaultValue="week">
<TabsList variant="pill" aria-label="Range">
<TabsTrigger value="day">Day</TabsTrigger>
<TabsTrigger value="week">Week</TabsTrigger>
<TabsTrigger value="month">Month</TabsTrigger>
</TabsList>
<TabsContent value="day">Day</TabsContent>
<TabsContent value="week">Week</TabsContent>
<TabsContent value="month">Month</TabsContent>
</Tabs>Usage examples
Counts
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@rookcom/monarch-react';
<Tabs defaultValue="open">
<TabsList aria-label="Requests">
<TabsTrigger value="open" count={6} countTone="accent">
Open
</TabsTrigger>
<TabsTrigger value="waiting" count={3} countTone="warning">
Waiting
</TabsTrigger>
<TabsTrigger value="closed" count={15}>
Closed
</TabsTrigger>
</TabsList>
<TabsContent value="open">Open requests</TabsContent>
<TabsContent value="waiting">Waiting requests</TabsContent>
<TabsContent value="closed">Closed requests</TabsContent>
</Tabs>Link tabs
import { TabsLink, TabsNav } from '@rookcom/monarch-react';
<TabsNav aria-label="Project sections">
<TabsLink href="#/components/tabs" active>
Overview
</TabsLink>
<TabsLink href="#/components/tabs" count={4}>
Issues
</TabsLink>
<TabsLink href="#/components/tabs">Settings</TabsLink>
</TabsNav>Filter tabs
import { useState } from 'react';
import { TabsFilter } from '@rookcom/monarch-react';
const STATUS = [
{ value: 'all', label: 'All', count: 24 },
{ value: 'open', label: 'Open', count: 6, countTone: 'accent' as const },
{ value: 'waiting', label: 'Waiting', count: 3, countTone: 'warning' as const },
{ value: 'closed', label: 'Closed', count: 15 },
];
function FilterDemo() {
const [status, setStatus] = useState('open');
return (
<TabsFilter
aria-label="Status"
options={STATUS}
scrollable
value={status}
onValueChange={setStatus}
>
Showing {STATUS.find((s) => s.value === status)?.label.toLowerCase()} requests.
</TabsFilter>
);
}
<FilterDemo />Scrollable
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@rookcom/monarch-react';
const SECTIONS = [
'Overview',
'Activity',
'Files',
'Members',
'Integrations',
'Billing',
'Audit log',
'Settings',
];
<div style={{ width: '100%', maxWidth: 320 }}>
<Tabs defaultValue="Overview">
<TabsList aria-label="Workspace sections" scrollable>
{SECTIONS.map((s) => (
<TabsTrigger key={s} value={s}>
{s}
</TabsTrigger>
))}
</TabsList>
{SECTIONS.map((s) => (
<TabsContent key={s} value={s}>
{s}
</TabsContent>
))}
</Tabs>
</div>URL binding
<p>
<code>useTabsUrlBinding</code> takes the value your router read and a setter, and
returns <code>value</code> / <code>onValueChange</code> for Tabs or TabsFilter.
</p>Purpose
Switches between panels of related content in the same place, showing one panel at a time.
Use when
- A page or card has sections that are peers and the user looks at one at a time (
line). - A compact switch between views of the same data, such as Day / Week / Month (
pill). - Sections of a record are separate routes but should look like tabs: TabsNav + TabsLink.
- Tabs filter one list rather than switching panels ("All · Open · Closed" with counts): TabsFilter.
Avoid when
- Each section is a separate page and should not look like tabs: use ordinary navigation links.
- The content is a sequence of steps: tabs imply free order.
- The user must compare panels side by side.
Design principles
linefor page sections,pillfor compact view switches.- Short, noun labels that do not wrap (
white-space: nowrap). - The active tab is shown by text colour plus an accent underline (
line) or a raised surface (pill), never by colour alone. - Semantics follow behaviour: panels use the tab pattern, pages use links with
aria-current, filters use tabs with one shared panel. - No router dependency: link tabs render your router’s link with
asChild, URL state is injected throughvalue/onValueChange(oruseTabsUrlBinding). - A count belongs in the tab’s name, so it is plain text inside the trigger, not a decorative badge.
API reference
import { Tabs, TabsContent, TabsFilter, TabsLink, TabsList, TabsNav, TabsTrigger, useTabsUrlBinding } from '@rookcom/monarch-react';| Export | Kind | Rendering | Root class |
|---|---|---|---|
Tabs | component | client | .mn-tabs |
TabsContent | component | client | .mn-tabs |
TabsFilter | component | client | .mn-tabs |
TabsLink | component | client | .mn-tabs |
TabsList | component | client | .mn-tabs |
TabsNav | component | client | .mn-tabs |
TabsTrigger | component | client | .mn-tabs |
useTabsUrlBinding | hook | client | – |
Tabs is the Radix Tabs Root: value / defaultValue / onValueChange, orientation, activationMode and dir come from @radix-ui/react-tabs.
TabsList, TabsTrigger and TabsContent forward their refs to the Radix parts.
TabsNav renders a <nav> (ref to it) and TabsLink an <a> (or the child with asChild); all anchor attributes pass through.
TabsFilter renders a wrapper <div> (ref) with the tablist and, when given children, one shared tabpanel. Its options are data: { value, label, count?, countTone?, disabled? }.
useTabsUrlBinding({ value, onChange, values, defaultValue, clearDefault }) returns { value, onValueChange } to spread on Tabs or TabsFilter: unknown URL values fall back to the default, and choosing the default writes null so the param is removed.
Generated from the TypeScript declarations of packages/react/src/components/navigation/tabs.
Properties
Tabs
Arrow keys move between tabs; Home/End jump to the ends.
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
id | string |
TabsContent
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
id | string |
TabsFilter
Tabs that filter one list instead of switching panels: "All 24 · Open 6 · Closed 18". Same keyboard model as Tabs (arrow keys, Home/End, roving focus) and the same look, with one shared, optional panel.
| Prop | Type | Description |
|---|---|---|
aria-label required | string | Names the filter set, for example "Status". Required. |
options required | readonly TabsFilterOption[] | |
activationMode | "manual" | "automatic" | `automatic` (default) selects a tab when it receives focus; `manual` waits for Enter or Space, for filters that are slow to apply. |
children | ReactNode | The filtered content. When given it is rendered in one shared `tabpanel` labelled by the selected tab, and every tab points at it with `aria-controls`. Leave it out when the results live elsewhere on the page. |
className | string | |
defaultValue | string | |
id | string | |
onValueChange | ((value: string) => void) | |
panelClassName | string | Class for the shared panel. |
scrollable | boolean | |
value | string | |
variant | TabsVariant |
TabsLink
| Prop | Type | Description |
|---|---|---|
active | boolean | The current page. Sets `aria-current="page"` and the active style. |
asChild | boolean | Render the child element (a router link such as Next.js `<Link>`) with tab styling. Monarch does not import a router. |
children | ReactNode | |
className | string | |
count | ReactNode | Count shown in a bubble after the label, for example open items. It is read as part of the tab's name ("Open 12"); pass a string such as `"12 new"` when the number alone is ambiguous. On TabsTrigger, not combined with `asChild` (the child must be the only element). |
countTone | TabsCountTone | Colour of the count bubble. Default `neutral`. |
id | string |
TabsList
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
id | string | |
scrollable | boolean | Scroll horizontally instead of overflowing when the tabs do not fit, as on phones. The focused tab is scrolled into view. Exposed as `data-scrollable`. |
variant | TabsVariant | `line` for page sections, `pill` for compact view switches. |
TabsNav
Tab-styled navigation between pages: a `<nav>` of TabsLink. Each link is a real link with its own URL, so it uses link semantics (`aria-current`), not the tab role, and Tab moves between links.
| Prop | Type | Description |
|---|---|---|
aria-label required | string | Names the navigation landmark, for example "Project sections". Required. |
children | ReactNode | |
className | string | |
id | string | |
scrollable | boolean | Scroll horizontally when the links do not fit. |
variant | TabsVariant |
TabsTrigger
| Prop | Type | Description |
|---|---|---|
asChild | boolean | |
children | ReactNode | |
className | string | |
count | ReactNode | Count shown in a bubble after the label, for example open items. It is read as part of the tab's name ("Open 12"); pass a string such as `"12 new"` when the number alone is ambiguous. On TabsTrigger, not combined with `asChild` (the child must be the only element). |
countTone | TabsCountTone | Colour of the count bubble. Default `neutral`. |
disabled | boolean | |
id | string |
Variants
| Prop | Values | Description |
|---|---|---|
variant (TabsList) | 'line''pill' | line (default) draws a bottom border with an accent underline on the active tab; pill is an inline, muted track with the active tab on a raised surface. Exposed as data-variant on the list. Also on TabsNav and TabsFilter. |
scrollable (TabsList, TabsNav, TabsFilter) | 'true''false' | The list scrolls sideways instead of overflowing when tabs do not fit (data-scrollable). The line border becomes an inset shadow so the underline is not clipped, and the focus ring is drawn inside. |
count / countTone (TabsTrigger, TabsLink, TabsFilter options) | 'ReactNode''neutral''accent''info''success''warning''danger' | A count bubble after the label (.mn-tabs__count, data-tone). Part of the accessible name. |
active (TabsLink) | 'true''false' | The current page: aria-current="page" and data-state="active". |
activationMode (TabsFilter) | 'automatic''manual' | automatic (default) applies a filter when its tab gets focus; manual waits for Enter or Space. |
States
| State | Exposed as | Behaviour |
|---|---|---|
| Active | data-state="active", aria-selected="true" | Default text colour; accent underline (line) or surface and shadow (pill). |
| Inactive | data-state="inactive" | Muted text colour. |
| Hover | – | Inactive triggers take the default text colour. |
| Disabled | data-disabled, disabled | Disabled text colour and a not-allowed cursor; skipped by arrow keys. |
| Focus | – | Triggers and panels show the Monarch focus ring on :focus-visible only. |
| Current page (TabsLink) | aria-current="page", data-state="active" | Styled exactly like an active tab. |
Interaction behaviour
- Clicking a trigger selects it and shows its panel.
- By default a tab is selected as soon as it receives focus (Radix
activationMode="automatic"); passactivationMode="manual"to select only with Enter or Space. - Only the active panel is rendered; inactive panels are unmounted unless
forceMountis set on TabsContent. - Controlled with
value+onValueChange, or uncontrolled withdefaultValue. TabsFilter works the same way; uncontrolled it starts on the first enabled option. - TabsLink navigates like any link; the app decides which one is
activefrom its router. - TabsFilter has no per-tab panels. With
children, one shared panel is labelled by the selected tab; without, the tabs carry noaria-controls. - In a scrollable list, focusing a tab scrolls it into view.
Keyboard support
| Keys | Action |
|---|---|
| Tab | Moves focus to the active tab, then into the active panel. |
| ArrowRight / ArrowLeft | Moves to the next or previous enabled tab (horizontal orientation), wrapping at the ends. |
| ArrowDown / ArrowUp | Same, when orientation="vertical". |
| Home / End | Moves to the first or last enabled tab. |
| Enter / Space | Selects the focused tab in manual activation mode. |
| Tab (TabsNav) | Link tabs are ordinary links: Tab moves between them and Enter follows the link. There is no arrow-key roving, because they are not a tablist. |
TabsFilter implements the same model as Tabs: ArrowLeft/Right (mirrored in right-to-left), Home/End, disabled tabs skipped, one tab stop.
Accessibility
- Role
- tablist, tab and tabpanel
- Accessible name
- Give TabsList an
aria-label(oraria-labelledby) naming the set, such as "Views".
- Triggers expose
aria-selectedandaria-controls; panels are labelled by their trigger. - Roving tabindex: only the active tab is in the Tab order.
- The active panel is focusable so keyboard users can reach content that has no focusable element.
- Verified with axe in jsdom and in Chromium, Firefox and WebKit on /components/tabs.
- TabsNav is a
navigationlandmark and requiresaria-label; TabsLink marks the current page witharia-current="page", neveraria-selected. - TabsFilter requires
aria-label; tabs exposearia-selected, andaria-controlsonly when the shared panel exists. - Counts are text inside the trigger, so "Open 6" is announced; pass a string such as "6 new" when the number alone is unclear.
Responsive behaviour
- The list is a single flex row that does not wrap and labels do not wrap. Set
scrollablewhen the tabs may not fit at 320px; otherwise keep the set small or usepill. - Trigger height comes from
--mn-control-mdand does not change with viewport width. - A
scrollablelist takes the width of its container (contain: inline-size), so it never widens the layout; give it a block or full-width parent.
Theme support
- Colours are Monarch semantic tokens only, so light, dark and more-contrast themes apply automatically.
- The
lineunderline uses--mn-color-accent-strong, so it follows the product accent. - The
pilltrack uses--mn-color-bg-mutedand the active pill--mn-color-bg-surfacewith--mn-shadow-xs. - Count tones use each intent’s
subtleandstrongtokens, which meet text contrast in every theme.
Do and don't
Do
- Name the tab list with
aria-label. - Keep one TabsContent per TabsTrigger, with matching
values. - Use
manualactivation when showing a panel is expensive (for example it fetches data).
Don't
- Do not use tabs for sequential steps or for primary site navigation.
- Do not nest tab sets inside a tab panel.
- Do not put essential actions only inside a non-default tab.
- Do not use Tabs (role tab) for links between pages: use TabsNav so the current page is announced and the URL works.
- Do not hand-roll
role="tab"buttons for filters: TabsFilter gives the keyboard model for free.
Related components
- SegmentedControl: SegmentedControl / ToggleGroup pick a value with radio or pressed semantics; use them for a compact view or option switch, and TabsFilter when the choice filters a list shown as tabs.
- Filter Chips (stable): Several independent filters at once, rather than one exclusive view.
- PageHeader: Has a
tabsslot for page-level section tabs. - Breadcrumb: Shows location across pages rather than sections of one page.
Dependencies
| Kind | Depends on |
|---|---|
| npm packages | @radix-ui/react-slot@radix-ui/react-tabsreact |
| Monarch components | None |
Testing coverage
- Tab focuses the active tab; ArrowRight moves focus and selects the next tab.
- The selected tab has
aria-selected="true"and its panel is shown. - No axe violations in jsdom.
- Renders on the server.
- Counts are part of the tab name and carry their tone;
scrollableis exposed;asChildstill works without a count. - TabsNav/TabsLink: navigation landmark,
aria-current="page"on the active link, router link viaasChild, Tab moves between links, no tab roles; SSR. - TabsFilter uncontrolled: roving tab stop, arrows skip disabled and wrap, Home/End, selection follows focus, no aria-controls without a panel.
- TabsFilter controlled with a shared panel and manual activation: panel labelled by the selected tab, Enter selects; one tab stop for an unknown value; SSR.
- useTabsUrlBinding: unknown and missing values fall back to the default, the default writes null unless
clearDefaultis false, unknown values are ignored, and it drives Tabs from injected URL state.
| Check | Covered |
|---|---|
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery; Cross-browser axe: /components/tabs |
Tests: packages/react/src/components/navigation/tabs