# Tabs Tabs organise related content into parallel sections that users can switch between without leaving the page. Section: components Docs: https://nebula.kaluza.com/components/tabs --- ## When to use - To present two or more distinct views of the same subject, where each view is complete on its own and the user only needs one at a time - To reduce visual complexity on a page that contains a large amount of related content - To let the user switch context without navigating to a separate page ## When not to use - **To show sequential steps or a wizard flow** - tabs communicate parallel content, not progression. Use a separate page per step instead. - **To compare content across sections** - if the user needs to move back and forth between tabs to make a comparison, the content should be visible on screen at the same time. Use a [Data Table](/components/data-table) or a [Summary List](/components/summary-list) instead. - **To show one or two sections** - the overhead of a tab strip is not worth it for only two sections where a simple heading would do. Use an [Accordion](/components/accordion) or plain headings instead. - **As primary navigation** - tabs belong inside a page, not at the site or application level. Use the application's navigation system instead. - **When section labels are long or numerous** - tabs that wrap to a second row or truncate labels are harder to scan. If the labels do not fit comfortably in a single row, use an [Accordion](/components/accordion) instead. ## Anatomy 1. **Tab list** (the horizontal strip containing all tab buttons) 2. **Tab** (an individual tab button; one is active at any time) 3. **Active indicator** (the coloured bar at the bottom of the active tab) 4. **Tab panel** (the content area associated with the active tab) ## Keyboard support ### Keyboard functions - **Left arrow:** move to the previous tab and activate it - **Right arrow:** move to the next tab and activate it - **Tab:** move focus into the active tab panel ## Basic usage ```tsx Tab 1 Tab 2 Tab 3

Tab 1 Content

Tab 2 Content

Tab 3 Content

``` ## Variants and features ### Default selected tab By default the first tab is active. Use `defaultSelected` to open a different tab on first render, passing a zero-based index. ```tsx Billing periods Direct Debit Supplies

Billing periods content

Direct Debit content - selected on first render

Supplies content

``` ### Controlled tabs Use `value` and `onSelected` together to control the active tab from outside the component. This is useful when the active tab needs to be driven by external state, such as a URL parameter or a parent component. `onSelected` fires with the zero-based index of the newly active tab. It does not fire on initial render. ```tsx const Example = () => { const [selected, setSelected] = useState(0); return ( Billing periods Direct Debit Supplies

Billing periods content

Direct Debit content

Supplies content

setSelected(2)}> Jump to Supplies
); } ``` ### Rich tab labels A `Tab` accepts any content, not just a text label. Keep labels short enough to stay scannable in a single row. ```tsx Account
123456789
Active
Account
234567890
Inactive

Details for account 123456789

Details for account 234567890

``` ### Tab panel visibility Inactive tab panels are hidden using `display: none`. They remain in the DOM, so their content is preserved across tab switches. There is no lazy rendering. All panel content mounts on initial render. ## Rules and guidance ### Usage - **Tabs** MUST NOT be used to represent sequential steps or a wizard flow. Rationale: Tabs communicate parallel content. Sequential steps require separate pages or a stepped form pattern so users understand they must complete each step in order. [id: tabs.parallel-content-only; testability: context] - **Tabs** MUST NOT be used as the primary navigation for a site or application. Rationale: Tabs are for switching between views within a page. Primary navigation belongs at the application shell level. [id: tabs.no-primary-navigation; testability: context] - **Tabs** MUST contain at least two tabs. Rationale: A single tab has no navigational purpose and adds visual noise without benefit. [id: tabs.minimum-two; testability: auto] - **Tabs** SHOULD NOT be used when the user needs to compare content across sections. Rationale: Switching between tabs to compare requires memory. If comparison is the goal, show the content side by side or in a table. [id: tabs.no-comparison; testability: context] ### Placement - **Tabs** SHOULD use the inline prop when placed inside a Card or other raised surface. Rationale: Without inline, Tabs renders its own Card surface, creating a nested raised surface with doubled borders and padding. [id: tabs.inline-inside-raised-surface; testability: manual] ### Content - **Tab labels** MUST be distinct from one another and unambiguous about what each panel contains. Rationale: Users need to know which tab to open before they open it. Labels that are too similar or too vague force trial-and-error navigation. [id: tabs.labels-mutually-distinct; testability: manual] - **Tab** MUST NOT use an icon as the sole content of a tab label. Rationale: Icons without text labels are inaccessible to screen reader users and ambiguous to sighted users unfamiliar with the icon. [id: tabs.no-icon-only-labels; testability: auto] - **Tab label** SHOULD be one or two words. Rationale: Short labels are faster to scan and are less likely to cause the tab strip to overflow at small viewport widths. [id: tabs.label-length; testability: manual] - **Tab label** SHOULD NOT end with punctuation. Rationale: Punctuation adds no meaning to a navigation label and creates visual noise. [id: tabs.no-terminal-punctuation; testability: auto] ### Interaction - **Tabs** WILL render with role=tablist on the list container, role=tab on each tab, and role=tabpanel on each panel. Do not override these roles. [id: tabs.aria-roles; testability: auto] - **Tab** WILL have aria-selected="true" when active and aria-selected="false" when inactive. The component manages this automatically via TabList. [id: tabs.aria-selected; testability: auto] - **TabPanel** WILL have an aria-describedby attribute referencing the id of its associated Tab. The component wires this up automatically via generated ids. [id: tabs.aria-describedby; testability: auto] - **Tabs** WILL respond to ArrowLeft and ArrowRight keys to move between tabs. The component implements this keyboard handler internally. [id: tabs.arrow-key-navigation; testability: auto] - **TabList** WILL keep only the active tab in the page tab sequence, using roving tabindex. The component manages tabIndex on each Tab automatically. [id: tabs.roving-tabindex; testability: auto] - **Tabs** WILL move focus to the newly activated tab when arrow keys are used. Focus and activation move together in the Nebula implementation. [id: tabs.focus-follows-arrow; testability: auto] ### Composition - **Tabs** MUST have a TabList as its first child. Rationale: The component clones the first child as the TabList and the remaining children as TabPanels. A different first child will receive TabList props incorrectly. [id: tabs.first-child-tablist; testability: auto] - **Tabs** MUST have the same number of TabPanel children as Tab children in the TabList. Rationale: The component maps panels to tabs by index. A mismatch results in panels receiving incorrect aria-describedby associations or tabs with no associated panel. [id: tabs.tabpanel-count-matches-tab-count; testability: auto] - **Tab** MUST NOT contain interactive elements such as buttons, links, or form controls. Rationale: Tabs themselves are interactive. Nesting interactive elements creates ambiguous keyboard behaviour and breaks the tab role. [id: tabs.no-interactive-elements-in-tab-labels; testability: auto] - **TabPanel** SHOULD NOT contain navigation that drives the user to a different tab. Rationale: Navigation inside a panel breaks the tab model and makes the interaction unpredictable for keyboard and screen reader users. [id: tabs.no-navigation-in-panel; testability: context] ## Accessibility ### In brief Tabs is a tab list with one panel for each tab. A screen reader announces each tab with its position and whether it is selected, and the panel is named by its tab. ### What Nebula provides - The tabs have the roles `tablist`, `tab` and `tabpanel`, and each tab reports whether it is selected with `aria-selected`. - Each panel is named by its tab with `aria-labelledby`, and only the selected panel is shown. - Only the selected tab is in the tab order. The left and right arrow keys move to the previous or next tab, select it and focus it. - A tab has a visible focus ring. ### Keyboard behaviour Only the selected tab is reached with `Tab`. The left and right arrow keys select and focus the previous or next tab, and do not wrap past the first or last. #### Focus order 1. The selected tab 2. The selected panel #### Interactions - **Tab** - Moves focus to the selected tab, then to its panel ### Accessible design #### Make each label stand for its whole panel The tab label is the only name the panel has, and it is also what a screen reader reads for the tab itself. Use the content of the tab to say what the panel holds, as in "Billing periods", and not "Other". WCAG: [2.4.6 Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) ### Accessible development #### Always pass one `TabPanel` for each `Tab`, in the same order A panel is named by the tab in the same position. A missing or extra panel leaves a tab with the wrong panel, or a panel with no name. WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) #### If a page has more than one set of tabs, name each `TabList` Extra props, such as `aria-label`, are passed on to the tab list. Without a name a screen reader user cannot tell two lists apart. WCAG: [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) #### Avoid changing `value` from code unless the user asked for it When `value` changes, that tab is selected and focused. Moving focus without a user action pulls them away from what they were doing. ### Accessibility testing status Reviewed on 9 October 2026 against v17.0.1. | Test | Status | Tested with | | --- | --- | --- | | Default state | Tested | axe-core via the Storybook test runner (CI) | | Advanced states | Partially tested | axe-core via the Storybook test runner (CI) | | Keyboard navigation | Partially tested | keyboard (reported by the owner) | | Screen reader | Partially tested | VoiceOver with Chrome (reported by the owner) | What each test covers: - Default state: The component as it first appears, before anything is clicked or changed. - Advanced states: Every other state and variant: error, open, selected, each size and style, and hovering. - Keyboard navigation: Whether every function can be reached and used with the keyboard alone, with a visible focus ring. - Screen reader: Whether a screen reader announces what is on screen correctly: the name, the role and any state. What these statuses mean: - Tested: Every check in this row passed in an automated run that repeats on every pull request. - Manually tested: Every check in this row was run once, by a person or with a tool they started, and all of them passed. It is not re-run on every change. - Partially tested: Some checks in this row were run once and passed; the rest are still to do. - Issue found: A check in this row failed. The ticket is shown beside the status. - Not tested: No check in this row has been run yet. - Not applicable: This row does not apply to the component, for the reason given beside it. The table records which checks were run on this component on its own. It is not a statement of WCAG conformance, and it does not cover how the component is used. ## Properties ### Tabs - `value` (type `number`, optional). Zero-based index of the selected tab (controlled). When it changes, that tab is selected and focused. - `defaultSelected` (type `number`, optional, default `0`). Zero-based index of the tab that is selected initially (uncontrolled). - `inline` (type `boolean`, optional). Has no effect. - `children` (type `ReactNode`, required). A `TabList` followed by one `TabPanel` for each tab, in the same order. - `onSelected` (type `(i: number) => void`, optional). Called with the zero-based index of the newly selected tab when the selection changes, but not on first render. It is also called again if the function reference changes, so pass a stable callback (for example from `useCallback`). - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. ### TabList - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. ### Tab Also accepts all props from `HTMLAttributes`, `LiHTMLAttributes`. - `active` (type `boolean`, optional). Whether the tab is selected. Set automatically by `TabList`, so you don't need to set it. - `onClick` (type `MouseEventHandler`, optional). Called after the tab is selected, in addition to selecting it, for example to send an analytics event. - `as` (type `ElementType`, optional). Renders the component as a different element or component, e.g. `as="section"`. - `forwardedAs` (type `ElementType`, optional). Like `as`, but passed on to the component being wrapped, for when a styled wrapper should keep its own element and change the one beneath it. ### TabPanel Also accepts all props from `HTMLAttributes`. ## Styles Reference only; you do not need this to use the component. This is the CSS its styled elements use, with theme tokens shown by name. `${...}` is an interpolation (a theme token, a mixin or another value from the code). A `&[prop]` or `@when ` block holds CSS that applies only under that condition; these are notation, not valid CSS. ### tab ```css font-size: ${responsiveFontSizes.body[0]}; font-family: ${fonts.display}; font-weight: ${active ? fontWeights.bold : fontWeights.normal}; line-height: ${lineHeights.body}; padding: ${space[3]} ${space[4]}; color: ${colors.heading}; flex-grow: 0; text-align: center; background-color: ${colors.canvas}; position: relative; overflow: hidden; cursor: pointer; ${mediaQueries.smallAndUp} { font-size: ${responsiveFontSizes.body[1]}; } &:first-child { border-top-left-radius: ${radii.standard}; } &:before { content: ''; position: absolute; background-color: ${active ? colors.primary : 'transparent'}; height: 0.3rem; bottom: 0; left: 0; right: 0; } &:focus-visible { ${focusRing} position: relative; z-index: 10; } &:hover { background-color: ${active ? 'transparent' : colors.canvasMuted}; } ``` ### tab-list ```css display: inline-flex; margin: 0; ``` ### tab-panel ```css padding: ${space[4]}; ${mediaQueries.smallAndUp} { padding: ${space[6]}; } display: ${hidden ? 'none' : 'block'}; border-top-width: ${borderWidths.standard}; border-style: solid; border-color: ${colors.borderMuted}; ``` ### tabs ```css padding: 0; ```