Composites
Route Tabs
A tab strip whose every item is a real route. It renders a <nav> of links and marks the current one with aria-current="page" — the same look as Tabs, with none of its tablist semantics.
It holds no state: the URL is the state, and pathname is how the URL reaches the component. On Next you must pass LinkComponent={Link} — see Wiring your router.
RouteTabs or Tabs?
The question is not how it looks — the two are indistinguishable, deliberately. The question is what happens when you click. If the content is already on the page and a click swaps a panel, that is Tabs. If a click changes the URL and loads a different page, that is RouteTabs.
| Tabs | RouteTabs | |
|---|---|---|
| A click… | swaps a panel on this page | navigates to another URL |
| Markup | role="tablist" / role="tab" / role="tabpanel" | <nav> of <a> |
| Active state | aria-selected | aria-current="page" |
| Keyboard | roving focus — one Tab stop, arrows move | plain links — Tab through, Enter follows |
| State lives in | Radix, inside the component | the URL — nothing is stored |
| Deep-linkable / back button | no, unless you sync it yourself | yes, for free |
Reaching for Tabs to navigate is not a style slip, it breaks three separate things. Radix wires aria-controls to a role="tabpanel" on the same page, and with no panel to point at that becomes a dangling id — a genuine accessibility failure, not a cosmetic one. role="tab" promises a screen reader that activating this swaps content in place, when in fact the page is about to be replaced. And the tablist's roving focus swallows the arrow keys, which a list of links has no business intercepting.
The reverse mistake is cheaper but still real: hand-rolling a <nav> of links and copying the tab classes into it. That copy drifts the first time somebody restyles tabs. RouteTabs and Tabs read one class table — see Tabs for the stateful sibling and the shared recipes.
Basic usage
The demo below is a real RouteTabs with a pretend router swapped in through LinkComponent, so clicking moves the active tab without taking you off this page. Inspect a tab and you will find aria-current="page" on exactly one of them.
pathname = "/settings/members"
Variants
The same two looks as Tabs: default is an underlined strip for page-level sections, pills is a padded muted track for tabs inside a panel or card. Unlike Tabs, you set the variant in one place — there is no separate list and trigger to keep in sync.
pathname = "/reports/weekly"
Both variants come from tabsListVariants, tabsTriggerVariants and TABS_TRIGGER_BASE, all exported from @comitor/ui. Those live in a module with no 'use client' directive precisely so both components can read them: Tabs is a client component, RouteTabs is not, and the RSC boundary is drawn per file. The triggers key off data-[state=active], which is why RouteTabs sets data-state on its links even though nothing about a link is stateful.
Matching the current route
By default an item is active when pathname === href. That is right for leaf routes and wrong the moment a tab has children: open a member's detail page at /settings/members/anna and the Members tab goes dark. match: 'prefix' fixes that — it matches the href itself, or anything below href + '/'.
The trap. Never put match: 'prefix' on the group's root href. /settings is a prefix of every sibling, so that item would light up on Members, on Billing, on everything — two tabs active at once, and two elements claiming aria-current="page". Leave the root item on the default exact match.
Click through the tabs below, then use the buttons to drop the pathname onto a child route and watch which tab holds.
pathname = "/settings"
Matching is a pure comparison against the string you pass, so query strings and hashes are yours to strip: usePathname() already returns the path alone, but a hand-built pathname carrying ?tab=2 will never match anything. Trailing slashes matter for the same reason.
Wiring your router
The package never imports next/link, so it still runs in an app that is not Next. The cost of that is one prop you must not forget: on Next, pass LinkComponent={Link}. Omit it and every tab is a raw <a href> that reloads the whole document — it looks identical and works, which is exactly why the mistake survives review. Same convention as Breadcrumb and AppShell.
The demo below is wired to this site's real router — usePathname() and next/link. The tabs really navigate, and the active one is picked by the URL you land on.
usePathname() = "/composites/route-tabs"
For any other router, write a five-line adapter. RouteTabsLinkProps is the whole contract, and the two easy-to-drop props are the ones that matter most: className carries the entire look, and data-state is what the class table keys off — an adapter that forgets it renders every tab inactive.
import { Link as RouterLink, useLocation } from 'react-router-dom'
import { RouteTabs, type RouteTabItem, type RouteTabsLinkProps } from '@comitor/ui'
// Any router works — the contract is five props wide, and every one of
// them has to reach the rendered anchor: className carries the whole look,
// data-state is what the class table keys off, aria-current is the state
// assistive tech reads.
function AppLink({ href, children, ...rest }: RouteTabsLinkProps) {
return (
<RouterLink to={href} {...rest}>
{children}
</RouterLink>
)
}
const ITEMS: RouteTabItem[] = [
{ href: '/settings', label: 'General' },
{ href: '/settings/members', label: 'Members', match: 'prefix' },
]
export function SettingsTabs() {
const { pathname } = useLocation()
return <RouteTabs pathname={pathname} LinkComponent={AppLink} label="Settings sections" items={ITEMS} />
}One note on server rendering: RouteTabs itself carries no 'use client' directive, so it will render in a Server Component if you already know the path — from a route segment, say. It is usePathname() that makes the calling file a client component, not the tabs.
Naming the nav
label becomes the aria-label of the <nav> landmark, and its default is the Vietnamese 'Mục của trang' — every display string the package ships defaults to Vietnamese, screen-reader-only ones included. Unlike the bigger composites, RouteTabs has no labels object to override: this single prop is the only string in the component, so pass it and you are done.
Make it distinct per nav, too. A page with a settings strip and a usage-range strip has two <nav> landmarks, and “navigation, navigation” in a landmark list helps nobody.
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { RouteTabs, type RouteTabItem } from '@comitor/ui'
const settingsItems: RouteTabItem[] = [
{ href: '/settings', label: 'General' },
{ href: '/settings/members', label: 'Members', match: 'prefix' },
]
const rangeItems: RouteTabItem[] = [
{ href: '/settings/usage/daily', label: 'Daily' },
{ href: '/settings/usage/weekly', label: 'Weekly' },
]
// The nav's accessible name defaults to the Vietnamese "Mục của trang".
// It is a plain string prop, not part of a labels object — pass it on
// every RouteTabs in an English UI, and make it unique per nav on a page.
export function SettingsPageTabs() {
const pathname = usePathname()
return (
<>
<RouteTabs label="Settings sections" pathname={pathname} LinkComponent={Link} items={settingsItems} />
<RouteTabs label="Usage range" pathname={pathname} LinkComponent={Link} items={rangeItems} variant="pills" />
</>
)
}Narrow screens
The strip scrolls horizontally rather than wrapping: overflow-x-auto on the list, shrink-0 whitespace-nowrap on each link. Wrapping was the alternative and it is worse — a two-word label breaking mid-tab doubles the height of the whole strip and the underline stops reading as one row. The demo is capped narrow so you can drag or shift-scroll it.
pathname = "/org/permissions"
One consequence worth knowing: a tab scrolled out of view is still in the tab order, and browsers scroll a focused link into view on their own, so keyboard users reach everything. Pointer users on a trackpad-less device do not get a scrollbar hint — if a strip routinely overflows, that is the signal to move those routes into a sidebar or a menu instead.
RouteTabs props
| Prop | Type | Default | Description |
|---|---|---|---|
itemsrequired | RouteTabItem[] | — | The tabs, in order. Keyed by href, so hrefs must be unique within one RouteTabs. |
pathnamerequired | string | — | The current path. RouteTabs holds no state at all — the URL is the state, and this prop is how it reaches the component. usePathname() on Next. |
variant | 'default' | 'pills' | 'default' | Underlined strip, or pills on a muted track. Reads the same class table as Tabs, so the two are visually identical. |
LinkComponent | ComponentType<RouteTabsLinkProps> | a plain <a> | Your router link. On Next pass LinkComponent={Link} — left out, every tab is a full page load. The package never imports next/link itself so it still runs outside Next. |
label | string | 'Mục của trang' | aria-label for the <nav> landmark. The default is Vietnamese — pass an English string, and a distinct one per nav on the page. |
className | string | — | Extra classes for the list strip (the div inside the nav), merged after the variant classes. The <nav> element itself takes no className. |
itemClassName | string | — | Extra classes appended to every link, after the variant classes and the shared focus/disabled base. |
There is no onChange and no value, and that is the whole design: navigation is the router's job, and a tab strip that also remembered a selection would have two sources of truth to disagree.
RouteTabItem
| Prop | Type | Default | Description |
|---|---|---|---|
hrefrequired | string | — | Destination, and the React key for the item. Compared against pathname to decide the active tab. |
labelrequired | ReactNode | — | What the tab reads. A node, so a count badge or an icon can ride along — keep it short, the strip does not wrap. |
match | 'exact' | 'prefix' | 'exact' | How this item claims the current path. 'exact' is pathname === href. 'prefix' also matches child routes (href, or anything under href + '/'). Never give the group's root href a prefix match. |
RouteTabsLinkProps
What RouteTabs hands your link component on every render. next/link satisfies it as-is.
| Prop | Type | Default | Description |
|---|---|---|---|
hrefrequired | string | — | Passed straight through from the item. |
className | string | — | The entire visual treatment — variant classes, focus ring, active state. Drop this and the tab renders as an unstyled link. |
childrenrequired | ReactNode | — | The item label. |
aria-current | 'page' | undefined | — | Set to "page" on the active item only. This is the state assistive tech reads; forward it. |
data-state | 'active' | 'inactive' | — | Always set. The shared class table targets data-[state=active], so a link component that swallows this prop renders every tab as inactive. |
Accessibility
- It is a navigation landmark, not a tablist. The wrapper is
<nav aria-label={label}>withdata-slot="route-tabs". Screen reader users find it in the landmark list and get told how many links it holds — neither of which a strip ofrole="tab"buttons would give them. - The current page is announced by
aria-current="page", set on the active link only and left undefined — not"false"— on the rest, so nothing announces the inactive ones as anything at all. - Keyboard behaviour is the browser's. Tab moves to the next link, Enter follows it, and modifier-clicks and middle-clicks open in a new tab because these are real anchors. Nothing here captures the arrow keys, which is the point: arrow keys belong to the page, and a tablist's roving focus would take them.
- Focus is visible on every item.
TABS_TRIGGER_BASEputs a 2pxring-ring/50with a 2px offset against--backgroundon:focus-visible— the identical ringTabsuses, from the same constant. - Colour is never the only cue — by necessity. In the
defaultvariant the active underline isborder-primary-ink, which in the approved light palette resolves to the brand gold at 1.86:1 — below the 3:1 WCAG 1.4.11 asks of a non-text cue, and listed by name in the package's register of accepted shortfalls. Two things carry the state alongside it: the label shifts frommuted-foregroundtoforeground, andaria-currentstates it outright. Apps that need the measured numbers ship the second palette with<html data-contrast="high">, where the token becomesgold-500at 6.51:1. - Do not give the root item a prefix match. Two items matching means two
aria-current="page"links in one nav, which tells a screen reader user they are on two pages at once. - Labels are read, so write them as destinations. The link text is the entire accessible name — there is no
aria-labelper item and no title attribute. Iflabelis a node containing an icon and a count, make sure the text part still names the page. - Overflow stays reachable. The strip scrolls instead of wrapping, and because the items are focusable links the browser scrolls them into view as focus moves — a scroll container of non-focusable content would not have that property.