TabList
The shared top-tab strip — one underline row, no panels, in either tablist or navigation semantics.
TabList is the strip itself. Tabs is TabList plus a
panel it owns; reach for TabList directly when the host owns the selected value
or when each tab is a route.
Labels
Follow the sentence-case tab-label convention. The component preserves the authored label, including proper names and acronyms.
Counts
count renders the number as a Badge and hides it from assistive tech;
countLabel is what a screen reader hears in its place, so the tab announces as
"Needs scoping 2 requests" rather than as a digit glued to the name. Pass both or
neither.
import { TabList } from '@cajon-ui/react';
const [bucket, setBucket] = useState('scoping');
<TabList
aria-label="Work request buckets"
value={bucket}
onChange={setBucket}
items={[
{ value: 'scoping', label: 'Needs scoping', count: 2, countLabel: '2 requests' },
{ value: 'all', label: 'All', count: 3, countLabel: '3 requests' },
]}
/>Navigation tabs
When each tab is a route, pass mode="nav". The strip becomes a <nav> and the
active tab carries aria-current="page" — not role="tab", which promises a
panel in this document that a link does not deliver.
Nav items are TabListNavItem: href is required and disabled is not offered.
A greyed-out link is still a link — it announces as one and clicking it still
navigates — so the only honest way to disable a destination is to leave it out of
items.
Cajón ships no router, so the host supplies the link through renderLink:
import Link from 'next/link';
import { TabList, type TabListLinkProps } from '@cajon-ui/react';
function renderNavLink({ href, children, ...rest }: TabListLinkProps) {
return <Link href={href} {...rest}>{children}</Link>;
}
<TabList
mode="nav"
aria-label="System sections"
value={activeTabId}
renderLink={renderNavLink}
items={tabs}
trailingSlot={<FollowButton />}
/>Behavior
-
Keyboard — in
tablistmode, ArrowLeft/ArrowRight move between enabled tabs and Home/End jump to the ends, with a roving tabindex so the strip is one tab stop. Whenvaluematches no item the first enabled tab holds that tab stop and the first arrow press opens the row at its head, so a strip with nothing selected is still operable.navmode adds none of this: a list of links is already something readers know how to walk. -
Overflow — the strip scrolls rather than wraps, because a wrapped second line pushes page content down by a variable amount on every route. Its scrollbar is hidden in both engines; wheel, trackpad, touch and keyboard scrolling still work.
-
flushdrops the first tab's leading padding, so its label lands exactly on the row's content edge instead of 12px inside it. It deliberately does not touch the gutter — that is--cajon-tablist-inline-padding, and the two compose:Want Set Full-bleed row of chrome (System, Projects rows) Neither — the default gutter is the design-system one Row flush to its container's edge flush, and the property to0Band spans the column, labels align to a centred measure flush, and the property to that measure's insetThe last is what the Requests row does: its band and bottom rule run the width of the surface while its labels line up with the heading below. Zeroing the gutter inside
flushwould put that declaration on the row's own element, where it would beat the inherited measure — which is whyflushleaves it alone. -
Row height — by default the row is as tall as its tabs. A host that wants it to line up with the bands beside it (the app's section tabs match the 60px selector band) sets
--cajon-tablist-min-heighton the row. The bottom rule counts inside that number, and the tabs stay vertically centred. -
trailingSlotrenders outside the scrolling strip, so a pinned control stays at the viewport edge instead of scrolling off with the tabs. -
Empty
itemsrenders nothing at all, so a host needs no guard of its own.
Props
| Prop | Type | Default | Notes |
|---|---|---|---|
items | TabListItem[] / TabListNavItem[] | — | Ordered tab definitions |
value | string | — | The selected tab's value; '' selects none, and the first enabled tab keeps the tab stop |
mode | 'tablist' | 'nav' | 'tablist' | Whether tabs swap a panel or navigate |
onChange | (value: string) => void | — | Required in tablist mode |
renderLink | (props: TabListLinkProps) => ReactElement | — | Required in nav mode |
trailingSlot | ReactNode | — | Pinned right of the strip, outside its scroll |
flush | boolean | false | Drops the first tab's leading padding so its label sits on the row's content edge |
aria-label | string | — | Names the tablist or the nav landmark |
TabListItem
| Field | Type | Notes |
|---|---|---|
value | string | Identity; compared against value |
label | ReactNode | Visible name |
icon | ReactNode | Optional leading glyph |
count | number | Optional count badge, hidden from assistive tech |
countLabel | string | Spoken form of count, e.g. 3 requests |
disabled | boolean | tablist mode only. Rendered, not hidden; skipped by arrow keys |
href | string | Required on a nav-mode item |
id | string | Overrides the generated tab id |
controls | string | Id of the tabpanel this tab controls |