Skip to content

Components · Navigation

Tabs

Line and pill tab sets, link tabs, filter tabs, counts and scrolling lists.

@rookcom/monarch-reactComponent

Line and pill

Board view
Week

Usage examples

Counts

Link tabs

Filter tabs

Scrollable

URL binding

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

  • line for page sections, pill for 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 through value / onValueChange (or useTabsUrlBinding).
  • 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';
ExportKindRenderingRoot class
Tabscomponentclient.mn-tabs
TabsContentcomponentclient.mn-tabs
TabsFiltercomponentclient.mn-tabs
TabsLinkcomponentclient.mn-tabs
TabsListcomponentclient.mn-tabs
TabsNavcomponentclient.mn-tabs
TabsTriggercomponentclient.mn-tabs
useTabsUrlBindinghookclient–

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.

PropTypeDescription
asChildboolean
childrenReactNode
classNamestring
idstring

TabsContent

PropTypeDescription
asChildboolean
childrenReactNode
classNamestring
idstring

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.

PropTypeDescription
aria-label requiredstringNames the filter set, for example "Status". Required.
options requiredreadonly 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.
childrenReactNodeThe 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.
classNamestring
defaultValuestring
idstring
onValueChange((value: string) => void)
panelClassNamestringClass for the shared panel.
scrollableboolean
valuestring
variantTabsVariant
PropTypeDescription
activebooleanThe current page. Sets `aria-current="page"` and the active style.
asChildbooleanRender the child element (a router link such as Next.js `<Link>`) with tab styling. Monarch does not import a router.
childrenReactNode
classNamestring
countReactNodeCount 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).
countToneTabsCountToneColour of the count bubble. Default `neutral`.
idstring

TabsList

PropTypeDescription
asChildboolean
childrenReactNode
classNamestring
idstring
scrollablebooleanScroll horizontally instead of overflowing when the tabs do not fit, as on phones. The focused tab is scrolled into view. Exposed as `data-scrollable`.
variantTabsVariant`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.

PropTypeDescription
aria-label requiredstringNames the navigation landmark, for example "Project sections". Required.
childrenReactNode
classNamestring
idstring
scrollablebooleanScroll horizontally when the links do not fit.
variantTabsVariant

TabsTrigger

PropTypeDescription
asChildboolean
childrenReactNode
classNamestring
countReactNodeCount 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).
countToneTabsCountToneColour of the count bubble. Default `neutral`.
disabledboolean
idstring

Variants

PropValuesDescription
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

StateExposed asBehaviour
Activedata-state="active", aria-selected="true"Default text colour; accent underline (line) or surface and shadow (pill).
Inactivedata-state="inactive"Muted text colour.
Hover–Inactive triggers take the default text colour.
Disableddata-disabled, disabledDisabled 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"); pass activationMode="manual" to select only with Enter or Space.
  • Only the active panel is rendered; inactive panels are unmounted unless forceMount is set on TabsContent.
  • Controlled with value + onValueChange, or uncontrolled with defaultValue. TabsFilter works the same way; uncontrolled it starts on the first enabled option.
  • TabsLink navigates like any link; the app decides which one is active from its router.
  • TabsFilter has no per-tab panels. With children, one shared panel is labelled by the selected tab; without, the tabs carry no aria-controls.
  • In a scrollable list, focusing a tab scrolls it into view.

Keyboard support

KeysAction
TabMoves focus to the active tab, then into the active panel.
ArrowRight / ArrowLeftMoves to the next or previous enabled tab (horizontal orientation), wrapping at the ends.
ArrowDown / ArrowUpSame, when orientation="vertical".
Home / EndMoves to the first or last enabled tab.
Enter / SpaceSelects 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 (or aria-labelledby) naming the set, such as "Views".
  • Triggers expose aria-selected and aria-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 navigation landmark and requires aria-label; TabsLink marks the current page with aria-current="page", never aria-selected.
  • TabsFilter requires aria-label; tabs expose aria-selected, and aria-controls only 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 scrollable when the tabs may not fit at 320px; otherwise keep the set small or use pill.
  • Trigger height comes from --mn-control-md and does not change with viewport width.
  • A scrollable list 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 line underline uses --mn-color-accent-strong, so it follows the product accent.
  • The pill track uses --mn-color-bg-muted and the active pill --mn-color-bg-surface with --mn-shadow-xs.
  • Count tones use each intent’s subtle and strong tokens, 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 manual activation 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.
  • 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 tabs slot for page-level section tabs.
  • Breadcrumb: Shows location across pages rather than sections of one page.

Dependencies

KindDepends on
npm packages@radix-ui/react-slot@radix-ui/react-tabsreact
Monarch componentsNone

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; scrollable is exposed; asChild still works without a count.
  • TabsNav/TabsLink: navigation landmark, aria-current="page" on the active link, router link via asChild, 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 clearDefault is false, unknown values are ignored, and it drives Tabs from injected URL state.
CheckCovered
Axe in jsdomYes
Server renderingYes
Browser suitesVisual baselines: /__gallery; Cross-browser axe: /components/tabs

Tests: packages/react/src/components/navigation/tabs