Cajón Design System
Components

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' },
  ]}
/>

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 tablist mode, ArrowLeft/ArrowRight move between enabled tabs and Home/End jump to the ends, with a roving tabindex so the strip is one tab stop. When value matches 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. nav mode 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.

  • flush drops 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:

    WantSet
    Full-bleed row of chrome (System, Projects rows)Neither — the default gutter is the design-system one
    Row flush to its container's edgeflush, and the property to 0
    Band spans the column, labels align to a centred measureflush, and the property to that measure's inset

    The 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 flush would put that declaration on the row's own element, where it would beat the inherited measure — which is why flush leaves 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-height on the row. The bottom rule counts inside that number, and the tabs stay vertically centred.

  • trailingSlot renders outside the scrolling strip, so a pinned control stays at the viewport edge instead of scrolling off with the tabs.

  • Empty items renders nothing at all, so a host needs no guard of its own.

Props

PropTypeDefaultNotes
itemsTabListItem[] / TabListNavItem[]—Ordered tab definitions
valuestring—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
trailingSlotReactNode—Pinned right of the strip, outside its scroll
flushbooleanfalseDrops the first tab's leading padding so its label sits on the row's content edge
aria-labelstring—Names the tablist or the nav landmark

TabListItem

FieldTypeNotes
valuestringIdentity; compared against value
labelReactNodeVisible name
iconReactNodeOptional leading glyph
countnumberOptional count badge, hidden from assistive tech
countLabelstringSpoken form of count, e.g. 3 requests
disabledbooleantablist mode only. Rendered, not hidden; skipped by arrow keys
hrefstringRequired on a nav-mode item
idstringOverrides the generated tab id
controlsstringId of the tabpanel this tab controls

On this page