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.

 TabsRouteTabs
A click…swaps a panel on this pagenavigates to another URL
Markuprole="tablist" / role="tab" / role="tabpanel"<nav> of <a>
Active statearia-selectedaria-current="page"
Keyboardroving focus — one Tab stop, arrows moveplain links — Tab through, Enter follows
State lives inRadix, inside the componentthe URL — nothing is stored
Deep-linkable / back buttonno, unless you sync it yourselfyes, 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.

app-link.tsx
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.

labels.tsx
'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.

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

PropTypeDefaultDescription
itemsrequiredRouteTabItem[]The tabs, in order. Keyed by href, so hrefs must be unique within one RouteTabs.
pathnamerequiredstringThe 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.
LinkComponentComponentType<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.
labelstring'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.
classNamestringExtra classes for the list strip (the div inside the nav), merged after the variant classes. The <nav> element itself takes no className.
itemClassNamestringExtra 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

PropTypeDefaultDescription
hrefrequiredstringDestination, and the React key for the item. Compared against pathname to decide the active tab.
labelrequiredReactNodeWhat 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.

PropTypeDefaultDescription
hrefrequiredstringPassed straight through from the item.
classNamestringThe entire visual treatment — variant classes, focus ring, active state. Drop this and the tab renders as an unstyled link.
childrenrequiredReactNodeThe item label.
aria-current'page' | undefinedSet 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}> with data-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 of role="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_BASE puts a 2px ring-ring/50 with a 2px offset against --background on :focus-visible — the identical ring Tabs uses, from the same constant.
  • Colour is never the only cue — by necessity. In the default variant the active underline is border-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 from muted-foreground to foreground, and aria-current states it outright. Apps that need the measured numbers ship the second palette with <html data-contrast="high">, where the token becomes gold-500 at 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-label per item and no title attribute. If label is 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.