Skip to main content

Tabs

stablev0.1.0

Compound tab navigation component. Organises content into labelled panels; only the active panel is visible at a time. Built on @radix-ui/react-tabs for full keyboard navigation and ARIA semantics.

Variants

Basic — active / default / disabled

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

With icon and badge

Tab with a leading icon (16×16, aria-hidden).

Icon slot sizes its child

The slot sizes whatever it receives to 16×16: this glyph was passed at 24px and reads like its neighbour, so a product icon set or a logo image needs no measuring by the caller.

Badge color (brand / error), max cap ("99+"), and conditional invisible state

Default badgeColor ("brand") — unchanged visual.

badgeMax boundary (0) and invalid values (negative/NaN/Infinity)

badge={3} badgeMax={0} renders "0+" — zero is a valid, deliberate cap (not the same as an unset badgeMax), so it is honored like any other non-negative value.

With actions — right-aligned, sibling of the tablist

Actions render as a DOM sibling of role="tablist" (never a child) — see the accessibility notes below.

Tab that opens a menu — TabsMenuTrigger (not a role="tab")

Overview panel.

With actions — overflow (narrow container: scroll + fade + arrows appear automatically; actions stay fixed)

Overview panel.

Vertical orientation — right rail, stacked triggers, actions below

Vertical rails share every TabsTrigger feature.

Vertical — overflow (fixed height: scroll + fade + up/down chevrons appear automatically)

Overview panel.

Vertical — long labels truncate with an ellipsis (full text via title; the rail never outgrows its width)

Overview panel.

panels="external" — panels rendered outside the tab set (route outlet, caller-managed) or none at all; triggers carry no aria-controls

The panel belongs to the caller here — a router outlet for route tabs (each trigger asChild over a <Link>), or a conditional render driven by value. Never mix the two strategies in one tab set.

Examples

<Tabs defaultValue="overview">
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="reports">Reports</TabsTrigger>
  </TabsList>
  <TabsContent value="overview">…</TabsContent>
  <TabsContent value="reports">…</TabsContent>
</Tabs>
// Icon (decorative, aria-hidden) and count badge on triggers
<TabsTrigger value="alerts" icon={<Icon name="clock" />} badge={3}>Alerts</TabsTrigger>
// badgeColor "error" (tinted red, WCAG AA in both modes — never a solid fill),
// badgeMax caps a numeric badge ("99+"), badgeInvisible hides it conditionally.
// The badge stays purely decorative in every combination — never clickable.
<TabsTrigger value="alerts" badge={failedCount} badgeColor="error" badgeMax={99} badgeInvisible={failedCount === 0}>
  Alerts
</TabsTrigger>
// A route-based tab bar: the panel is the router outlet, so the tab set owns
// no panel and declares panels="external". Without it, every trigger keeps an
// aria-controls pointing at a TabsContent that is never rendered (axe
// aria-valid-attr-value, critical, on the selected trigger). Derive value from
// the URL (RULE-02); add aria-current="page" on the anchor so the destination is
// announced as the current page, not only as the selected tab.
<Tabs value={currentTab} panels="external">
  <TabsList>
    {SCREENS.map((s) => (
      <TabsTrigger key={s.id} value={s.id} asChild>
        <Link to={s.path} aria-current={s.id === currentTab ? "page" : undefined}>{s.label}</Link>
      </TabsTrigger>
    ))}
  </TabsList>
</Tabs>
// asChild for router links; icon/badge (and badgeColor/badgeMax/badgeInvisible) are
// ignored with asChild. This is also how a *clickable* badge/chip (e.g. OpenCTI's
// EEChip) must be composed — never by making TabsTrigger's own badge interactive.
<TabsTrigger value="settings" asChild>
  <Link to="/settings">Settings</Link>
</TabsTrigger>
// actions renders as a DOM sibling of role="tablist", never a child — right-aligned,
// 32px minimum gap from the tabs in both the normal and overflow states. On overflow,
// scroll arrows + a decorative fade appear automatically; actions stay fixed.
// The 8px gap between actions is guaranteed by the slot itself (its wrapper is a
// flex container with its own gap) — pass siblings directly, no extra wrapping div
// needed. Secondary action(s) first, primary last (closest to the edge).
<TabsList
  actions={
    <>
      <Button priority="secondary" size="sm">Export</Button>
      <Button priority="primary" size="sm">New</Button>
    </>
  }
>
  <TabsTrigger value="overview">Overview</TabsTrigger>
  <TabsTrigger value="reports">Reports</TabsTrigger>
</TabsList>
// A tab that opens a menu (OpenCTI "Custom view"). Authored among the tabs but
// lifted OUT of role="tablist": it is a menu button (aria-haspopup="menu"), not a
// role="tab". Wire it with a CONTROLLED Tabs: the view ids double as Tabs values,
// so `active` is DERIVED from the current value (never hard-coded) — selecting a
// real tab clears it, selecting a view shows that panel and marks it active.
const VIEWS = [{ id: "cv-threats", label: "Threats overview" }, { id: "cv-campaign", label: "Campaign timeline" }];
const [value, setValue] = useState("overview");
const isCustomView = VIEWS.some((v) => v.id === value);

<Tabs value={value} onValueChange={setValue}>
  <TabsList>
    <TabsTrigger value="overview">Overview</TabsTrigger>
    <TabsTrigger value="data">Data</TabsTrigger>
    <TabsMenuTrigger
      active={isCustomView}
      menu={VIEWS.map((v) => (
        <MenuItem key={v.id} selected={value === v.id} onSelect={() => setValue(v.id)}>{v.label}</MenuItem>
      ))}
    >
      Custom view
    </TabsMenuTrigger>
  </TabsList>
  <TabsContent value="overview">…</TabsContent>
  {VIEWS.map((v) => <TabsContent key={v.id} value={v.id}>{v.label} view…</TabsContent>)}
</Tabs>

Props

PropType
Tabs.defaultValuestring — initially selected tab value (uncontrolled)
Tabs.valuestring — controlled selected tab value
Tabs.onValueChange(value: string) => void — called when active tab changes
Tabs.orientation"horizontal" | "vertical" — layout + keyboard-navigation axis. "vertical" renders a right rail (stacked triggers, right active indicator, a TabsMenuTrigger hugging the last tab, actions pinned to the bottom, up/down overflow chevrons) and moves arrow-key navigation to ↑/↓ (default: horizontal)
Tabs.panels"content" | "external" — where this tab set's panels live (default: "content"). "content": panels are TabsContent children and each trigger points at its own through aria-controls. "external": the panels are rendered outside this root (a router outlet for route-based tabs, a conditional render driven by value) or there is no panel at all — triggers then render NO aria-controls, because Radix would otherwise point it at the id of a TabsContent that is never rendered (axe aria-valid-attr-value, critical, on the selected trigger). Mirrored on the root as data-panels. Never mix the two strategies in one tab set (RULE-03): a TabsContent under panels="external" warns in development.
Tabs.activationMode"automatic" | "manual" — whether tabs activate on focus or on Enter/Space (default: "manual", overriding Radix's own "automatic" default — aligns with this design system's actual shipped behavior in MUI/OpenCTI; pass activationMode="automatic" to opt back into focus-driven activation).
TabsList.classNamestring — additional CSS classes for the tab bar
TabsList.actionsReactNode — optional content aligned to the far end of the bar, outside the role=tablist (e.g. secondary actions, status). Horizontal: right-aligned (ml-auto). Vertical: pinned to the bottom of the rail (mt-auto), full-width; a TabsMenuTrigger, by contrast, hugs the last tab rather than travelling to the far end.
TabsTrigger.valuestring (required) — unique key that links trigger to content panel
TabsTrigger.disabledboolean — prevents the tab from being selected
TabsTrigger.asChildboolean — merge trigger styles onto a single child element (e.g. React Router <Link>) instead of rendering a <button>. The child is cloned so its OWN children go through the active-width reservation — without it a route-based bar, where every trigger is asChild, reflowed sideways on each selection. icon and badge (including badgeColor, badgeMax, badgeLabel, badgeInvisible) are ignored when asChild is true — this is also the supported way to compose a clickable badge/chip (e.g. OpenCTI's EEChip): TabsTrigger's own badge stays purely decorative and is never clickable.
TabsTrigger.iconReactNode — optional 16×16 leading icon (decorative, aria-hidden). Ignored when asChild is true.
TabsTrigger.badgenumber | string — optional count badge displayed next to the label. Ignored when asChild is true.
TabsTrigger.badgeColor"brand" | "error" — semantic color variant for the badge (default: "brand", visually identical to the pre-existing badge). Purely decorative — never implies interactivity or clickability. Ignored when asChild is true or badge is undefined.
TabsTrigger.badgeMaxnumber — caps a numeric badge: values greater than badgeMax render as "${badgeMax}+" (e.g. badge={150} badgeMax={99} → "99+"). No default (uncapped) — ignored for string badges and when asChild is true. A negative or non-finite badgeMax (e.g. -1, NaN, Infinity) is treated as uncapped rather than applied literally.
TabsTrigger.badgeLabelstring — accessible name for the badge. The default, "${badge} items", is English and a product cannot translate it: a French UI announced "5 items". Pass the localised string instead. Ignored when asChild is true or the badge is not rendered.
TabsTrigger.badgeInvisibleboolean — hides the badge entirely when true (no DOM node rendered at all, not a CSS-only hide), equivalent to omitting badge. Default: false. Ignored when asChild is true.
TabsContent.valuestring (required) — must match the corresponding TabsTrigger value
TabsMenuTrigger (usage note)A tab-looking control that opens a dropdown instead of switching a panel (OpenCTI "Custom view"). Authored among the TabsList children but lifted OUT of role="tablist" and rendered inline after the last tab — it is a menu button (aria-haspopup="menu"), NOT a role="tab", so it never breaks aria-required-children. Composes the FDS Menu internally.
TabsMenuTrigger.childrenReactNode — the trigger's visible label (e.g. "Custom view"), kept stable (does not change to the selected entry's name). A string is width-reserved at its bold size so the active-state weight change never reflows the row.
TabsMenuTrigger.menuReactNode — the dropdown contents (MenuItem/MenuSeparator/…), rendered inside a MenuContent. Mark the current entry with <MenuItem selected>.
TabsMenuTrigger.activeboolean — shows the active indicator (brand underline + bold) when one of the menu entries is the current view. DERIVE it from state (e.g. `views.some(v => v.id === value)` on a controlled Tabs) — never hard-code `true`, or the trigger stays active after a real tab is selected. The trigger also shows the active treatment automatically while its menu is open. Exposed as data-active. Default: false.
TabsMenuTrigger.iconReactNode — optional 16×16 leading icon (decorative, aria-hidden).
TabsMenuTrigger.disabledboolean — disables the trigger and prevents the menu from opening.
TabsMenuTrigger.menuPropsOmit<MenuProps, 'children'> — forwarded to the Menu root (controlled/uncontrolled open, modal, dir).
TabsMenuTrigger.menuContentPropsMenuContentProps — forwarded to MenuContent (align, side, sideOffset, portalled…). Defaults keep Figma's placement: opens below, start-aligned. Focus return is modality-aware (WCAG 2.4.3): after a KEYBOARD dismissal (Escape, or opening with Enter/Space then closing) focus returns to the trigger so keyboard users are never dropped at the top of the document; after a MOUSE pick focus is NOT returned, so no focus ring lingers on the trigger once the pointer selected a view. Pass your own onCloseAutoFocus to override.
TabsContent.forceMountboolean — keeps content in the DOM even when inactive (useful for animations)
TabsContent (usage note)TabsContent is optional. When content is managed externally (conditional rendering, CSS display:none, a router outlet), omit TabsContent entirely AND declare panels="external" on the Tabs root — omitting the panel alone leaves each trigger with an aria-controls pointing at a panel that is never rendered. aria-selected, keyboard navigation and the roving tab order are unaffected by the choice.

Accessibility

2.1 AAWCAG pass

Criteria: 1.1.1 Non-text Content — decorative icons carry aria-hidden='true' inside a 16×16 slot that sizes its child with !important (an Icon, a product SVG icon — MUI small is 20px, its default 24px — or a logo image all read at 16×16, an image keeping its aspect ratio; the !important is what beats a CSS-in-JS icon's own width/height, measured — §9 Q5); badge renders an aria-label with count, computed on the displayed (post-badgeMax-cap) value so the accessible name never diverges from what is visually shown; badgeInvisible omits the badge element from the DOM entirely (no residual accessibility-tree node). 1.3.1 Info and Relationships — Radix provides role='tablist', role='tab', role='tabpanel' and aria-selected/aria-controls/aria-labelledby automatically. 1.4.1 Use of Color — active tab is differentiated by a bottom indicator border (shape) AND heavier font weight AND brand colour; not colour alone. 1.4.1 Use of Color (overflow) — the truncation fade is a decorative cue only; overflow is also conveyed by the focusable scroll arrows and by the affected tab being visibly clipped at the scroll edge, so gradient/colour is never the sole indicator. 1.4.3 Contrast (Minimum) — all text combinations ≥ 4.5:1 in both modes (verified below). 1.4.11 Non-text Contrast — focus indicator ≥ 3:1 against page background in both modes (same brand colour on triggers and panels, so the measured pair below covers both). The TabsList bottom border uses border-elevation-subtle (decorative separator, not a functional UI component boundary) — exempt from WCAG 1.4.11 per SC 1.4.11 Note 1. TabsList.actions (and the scroll-arrow buttons / fade overlays rendered only while the tablist overflows) are DOM siblings of role='tablist', never children — ARIA's aria-required-children restricts a tablist's children to role='tab' elements, so any other descendant would misreport the accessible tree to assistive technology. Known limitation: actions use standard Tab order; toolbar roving-tabindex pattern deferred — see AI-BACKLOG. 2.1.1 Keyboard — Arrow keys move focus between tabs without activating them (manual activation is the default — see Tabs.activationMode); Enter/Space activates the focused tab; Tab moves to panel; Shift+Tab returns to trigger. In orientation='vertical' the roving arrows are Up/Down (↑/↓) instead of Left/Right, with Home/End jumping to the first/last tab (Radix follows the WAI-ARIA tabs pattern per orientation). 2.1.1 Keyboard (actions) — with actions present, Tab order is: active trigger → scroll-right arrow (only while overflowing) → actions → panel; scroll-left/right buttons are native <button> elements with aria-label='Scroll tabs left'/'Scroll tabs right', focusable and Enter/Space-activatable like any button (see Tabs.keyboard.test.tsx). In orientation='vertical' the same overflow arrows scroll the rail and are labelled aria-label='Scroll tabs up'/'Scroll tabs down' with an up/down chevron glyph (see Tabs.vertical.test.tsx). 4.1.2 Name, Role, Value (TabsMenuTrigger) — the 'menu tab' (OpenCTI "Custom view") is a menu button, NOT a role='tab': it exposes aria-haspopup='menu' and aria-expanded, is lifted OUT of role='tablist' and rendered as a DOM sibling after the last tab so aria-required-children stays satisfied. Its visible label is serialised exactly once (a single text node — the bold-weight width reservation uses a CSS ::after pseudo-element that carries no accessible text, so screen readers never announce the label twice). Keyboard dismissal returns focus to the trigger; a mouse pick does not (2.4.3 Focus Order, verified in Tabs.menu-trigger.test.tsx and in a real browser). 2.4.7 Focus Visible — triggers draw the indicator INSIDE their box (outline-2 with a negative outline-offset), same 2px and same brand colour as the standard ring: TabsList scrolls on the main axis, and a scroll container clips whatever its children paint outside their own box (a used overflow-x:auto also promotes overflow-y to auto), which sliced a ring + offset along the strip's top and bottom edges. TabsContent is not inside that container and keeps the standard ring-2 ring-focus pattern. 4.1.2 Name, Role, Value — Radix manages aria-selected, aria-controls, aria-labelledby; disabled tab carries HTML disabled attribute. 4.1.2 Name, Role, Value (panels="external") — Radix emits aria-controls on every trigger unconditionally, holding the id it WOULD give that value's TabsContent. In a tab set that renders no panel (route-based navigation bars, panels managed by the caller) that id resolves to no element: axe reports aria-valid-attr-value, impact critical, on the selected trigger — measured on both shapes, clean with a panel, critical without. Declaring panels="external" drops the attribute for that tab set only; an explicit aria-controls from the caller (an external panel with an id they own) still wins in both strategies. Both shapes are covered by axe in Tabs.panels.test.tsx and by the auto-discovered conformity suite, which runs axe on the demo — where the panel-less shape is now one of the showcased blocks. 4.1.2 Name, Role, Value (badge) — the badge (both colors, capped or not) is purely decorative and never interactive: no click handler, no role='button', no tabIndex, in any badgeColor/badgeMax/badgeInvisible combination. A clickable badge/chip (e.g. OpenCTI's EEChip) must be composed above TabsTrigger via asChild, never by making this badge interactive. 508 §1194.21(a) — all tab functions operable via keyboard. 508 §1194.21(c) — focus indication always visible: inset outline on the triggers, ring on the content panels. Active tab text dark: #42caff on #070d18 = 10.30:1 ✅ (≥ 4.5:1). Active tab text light: #0015a8 on #f2f2f3 = 11.22:1 ✅ (≥ 4.5:1). Default tab text dark: #afb0b6 on #070d18 = 8.97:1 ✅ (≥ 4.5:1). Default tab text light: #494a50 on #f2f2f3 = 7.88:1 ✅ (≥ 4.5:1). Badge brand (bg-filigran-brand-primary-transparency-10, --color-filigran-brand-primary-transparency-10 composited over --bg-elevation-default) text dark: #f2f2f3 on #0d202f = 14.85:1 ✅ (≥ 4.5:1). Badge brand text light: #18191b on #dadcec = 12.91:1 ✅ (≥ 4.5:1). Badge error (bg-alert-error, resolving to --bg-alert-error — implemented via the identical --color-feedback-error-secondary-transparency-30 formula, composited over --bg-elevation-default) text dark: #f2f2f3 on #2e0e13 = 15.80:1 ✅ (≥ 4.5:1). Badge error text light: #18191b on #f3ccc9 = 11.94:1 ✅ (≥ 4.5:1). A tinted/transparent background was chosen deliberately over a solid fill, for consistency with the existing brand badge and the design system's established bg-alert-* 30%-opacity pattern (info/success/alert/warning/error all share the same recipe) — not to work around a Button-specific issue: Button's destructive variant (solid bg-feedback-error-primary) currently passes WCAG AA (4.69:1 dark, 5.92:1 light, see Button.meta.ts / pnpm check:contrast) after its red primitives were hardened by the Button RFC rework (PR #37). Overflow fade composite (fade painted over tab text, contrasted against the page background — worst case is the fade's 20%-opacity 'from' stop nearest the scroll edge): default dark #afb0b6 + 20% fade → #8d8f96 on #070d18 = 6.05:1 ✅ (≥ 4.5:1). Default light #494a50 + 20% fade → #6b6c71 on #f2f2f3 = 4.71:1 ✅ (≥ 4.5:1, worst case — 20% opacity was chosen because default/light tolerates at most 21.83% before failing 4.5:1). Active dark #42caff + 20% fade → #36a4d1 on #070d18 = 6.85:1 ✅ (≥ 4.5:1). Active light #0015a8 + 20% fade → #3041b7 on #f2f2f3 = 7.30:1 ✅ (≥ 4.5:1). Known limitation: the fade uses from-fade-overlay (a dedicated @utility applying 20% of --bg-elevation-default-layer-0 via color-mix) — it cannot be expressed as a contrastPairs entry / verified by pnpm check:contrast. Re-verify these numbers by hand if the fade's color or opacity ever changes. Disabled tab text: exempt per WCAG 1.4.3 Note 1 (inactive UI component). Focus ring dark: #42caff on #070d18 = 10.30:1 ✅ (≥ 3:1). Focus ring light: #0015a8 on #f2f2f3 = 11.22:1 ✅ (≥ 3:1). List border (decorative separator): exempt from WCAG 1.4.11. Automated checks cover ~half of WCAG — human/screen-reader audit cadence still applies per release.