Tabs

Tabs switch between related views in the same context. Use them when content splits into peer sections, such as a space's overview, lessons and students.

  • Beta
  • Accessibility
  • Figma Tab Menu Horizontal 259:14003
  • packages/ui/src/components/Tabs

Beta until tab items get a designed focus state (Figma has none, as C10) is designed. This is pending a decision. Everything else on this page is implemented and tested.

Preview

Change the controls to try every supported combination. The preview renders the real component, the code updates with it, and the arrow keys work inside the tab bar.

Biology 101 has 12 lessons, 28 students and 3 assignments to mark this week.

tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@nuton/ui'
import { RiBookOpenLine, RiFileList3Line, RiGroupLine, RiLayoutGridLine } from '@remixicon/react'

<Tabs defaultValue="overview">
  <TabsList aria-label="Biology 101 sections">
    <TabsTrigger value="overview" leadingIcon={<RiLayoutGridLine />}>Overview</TabsTrigger>
    <TabsTrigger value="assignments" leadingIcon={<RiFileList3Line />} count={3} countLabel="3 to mark">Assignments</TabsTrigger>
    <TabsTrigger value="lessons" leadingIcon={<RiBookOpenLine />}>Lessons</TabsTrigger>
    <TabsTrigger value="students" leadingIcon={<RiGroupLine />}>Students</TabsTrigger>
  </TabsList>
  <TabsContent value="overview"><p>Biology 101 has 12 lessons, 28 students and 3 assignments to mark this week.</p></TabsContent>
  <TabsContent value="assignments"><p>Three assignments are waiting to be marked: the cell diagram, the microscope lab and the weekly quiz.</p></TabsContent>
  <TabsContent value="lessons"><p>Cell structure is next, followed by Photosynthesis and Respiration.</p></TabsContent>
  <TabsContent value="students"><p>28 students are enrolled in this space, and 4 joined this week.</p></TabsContent>
</Tabs>

Installation

Availability

@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.

1. Add the packages

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer dependencies: react and react-dom 18 or later. Tabs are built on Radix Tabs, which @nuton/ui installs for you. @remixicon/react supplies the icons you pass to triggers (D1).

2. Load tokens and component styles once

tsx
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css'     // component styles (uses the tokens)

The components are client components ('use client'). You can render them from a server component, but pass onValueChange only from a client component.

Usage

tsx
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@nuton/ui'

export function SpaceSections() {
  return (
    <Tabs defaultValue="lessons">
      <TabsList aria-label="Biology 101 sections">
        <TabsTrigger value="lessons">Lessons</TabsTrigger>
        <TabsTrigger value="students">Students</TabsTrigger>
        <TabsTrigger value="resources">Resources</TabsTrigger>
      </TabsList>
      <TabsContent value="lessons">12 lessons in this space.</TabsContent>
      <TabsContent value="students">28 students enrolled.</TabsContent>
      <TabsContent value="resources">Slides, readings and lab sheets.</TabsContent>
    </Tabs>
  )
}
  • Four parts: Tabs holds the state, TabsList is the bar, TabsTrigger is one tab and TabsContent is its panel. Match each trigger and panel with the same value.
  • Set defaultValue for uncontrolled tabs, or value with onValueChange when the selected tab lives in your state or the URL.
  • Give TabsList an aria-label that names the set, such as “Biology 101 sections”.
  • Labels are one or two words in sentence case: “Lessons”, “Lab sheets”. Keep 2–6 tabs, as in Figma.
  • Inactive panels aren't rendered (Radix default). Keep form drafts in state above Tabs, or pass forceMount and hide the panel yourself.

Variants and sizes

Tabs have one look and one size. What changes is the content of each trigger: a label, plus an optional leading icon, count badge and trailing icon, and an optional action at the end of the bar.

Label only

12 lessons in this space.

Icons

leadingIcon is Figma's Left icon (20px). It turns State/Primary/Base on the active tab. trailingIcon is Figma's Right icon: an 18px icon in a 20px round frame that stays Icon/Sub-600 in every state. Both are decorative and hidden from screen readers.

A summary of the space.

The trailing icon is decoration only. A trigger can't open a menu, so don't use a chevron to suggest one.

Count badge

count is Figma's Number: a 16px red badge after the label. It's part of the tab's name, so a screen reader hears “Assignments 3”. Add countLabel to say what the number means; it replaces the number in the name.

3 assignments to mark.

Contrast: count badge (pending a decision)

The badge is white text on Red 700, 5.26:1. Figma's State/Error/Base was 4.15:1, below AA for text this small (C6). The remote Figma value #fb3748 measures 3.66:1, so code uses the local token. The badge's text style couldn't be read from Figma (the remote Badge internals are empty), so Subheading/2X Small is unverified. Both are pending a decision.

Bar action

action is Figma's Button option: one control at the end of the bar. It's rendered outside the tab list, because a tab list may only contain tabs, so it's reached with Tab, not the arrow keys.

12 lessons in this space.

Figma draws a 24px compact button here. @nuton/ui has no 24px button, so the closest is a small icon-only Button (36 × 36), which fits the 48px bar. A compact button size is pending a decision.

Sizes

PartSizeNotes
Bar48 high1px Stroke/Soft-200 top and bottom, Bg/White, 14px block padding
Trigger row20 highLabel/Small (14/20). The trigger fills the bar below its top line (47px), so the whole height is the click target.
Space between tabs24A 20px gap plus 2px inline padding on each trigger, which leaves room for the focus outline
Icon to label6Label to count: 4
Leading icon20 × 20Icon/Sub-600; State/Primary/Base when active
Trailing icon20 × 20 frame, 18 iconRound Bg/White frame, Icon/Sub-600 in every state
Count badge16 high, 16 minimum widthPill, grows with the number
Active line2 highAs wide as the trigger's content, on the bar's bottom edge
Action slot12 inline paddingPinned to the end of the bar

Sizes come from the component CSS, which was checked against Figma's geometry (bar 48, item gap 6, text gap 4, 24 between tabs). Figma centres the 2px active line on the bar's bottom edge, so it spills 1px outside. Code draws it inside the trigger so the scrolling tab list doesn't clip it, a 1px difference pending a decision.

Narrow screens

Figma doesn't say what happens when tabs don't fit. In code the tabs scroll sideways inside the bar, never the page, and the focused tab scrolls into view as the arrow keys move. This preview limits the width so the six tabs scroll.

A summary of the space.

Gap: vertical tabs and other styles

The Figma page also shows a vertical tab menu, but it has no components, so it isn't built. orientation="vertical"switches the arrow keys to Up and Down, but there are no vertical styles. There are no segmented styles or size variants either.

States and interactions

DefaultText/Sub-600 label and Icon/Sub-600 icon.
HoverText/Strong-950 and Icon/Strong-950. Shown here statically; applies only on devices with hover.
ActiveText/Strong-950, a State/Primary/Base icon and a 2px State/Primary/Base line. Sets aria-selected.
DisabledNot in Figma. Uses the shared Text/Disabled-500 and Icon/Disabled-300. The arrow keys skip it.

12 lessons in this space.

FocusFigma defines none (as C10). The browser's outline is kept, inset 2px so the tab list doesn't clip it. Tab here to see it. A designed focus state is pending a decision.
No distinct style
PressedFigma defines no pressed state, so a press shows hover, then active.

State tokens

Set on .nu-tabs. Override them on a wrapper only for a documented exception.

TokenValueUsed for
--nu-tabs-fgText/Sub-600Inactive label
--nu-tabs-fg-hoverText/Strong-950Label on hover
--nu-tabs-fg-activeText/Strong-950Active label
--nu-tabs-iconIcon/Sub-600Inactive leading icon
--nu-tabs-icon-hoverIcon/Strong-950Leading icon on hover
--nu-tabs-icon-activeState/Primary/BaseActive leading icon
--nu-tabs-indicatorState/Primary/BaseActive line
--nu-tabs-indicator-size2pxActive line height
--nu-tabs-borderStroke/Soft-200Bar top and bottom lines
--nu-tabs-bgBg/WhiteBar background
--nu-tabs-gap--nu-space-5 (20px)Gap between triggers
--nu-tabs-pad-block--nu-space-3-5 (14px)Trigger block padding
--nu-tabs-icon-size · --nu-tabs-trailing-icon-size · --nu-tabs-count-size20px · 18px · 16pxIcon, trailing icon and badge sizes

Composition examples

Space sections

The main navigation inside a space: icons for quick scanning, a count where something needs attention, and an action for the most common task.

12 lessons, 28 students and a quiz due on Friday.

Controlled tabs

Hold the value in state when something outside the tab bar changes the tab, or when the tab should be kept in the URL.

Cells are the basic unit of life. This lesson covers the nucleus, the membrane and the mitochondria.

Selected tab: overview

Manual activation

By default a tab opens as soon as it's focused. When a panel is slow to load, set activationMode="manual": the arrow keys move focus only, and Enter or Space opens the panel.

4 lessons completed and 2 quizzes passed this week.

This week’s progress is showing.

Tabs switch panels on the same page. When each section is its own page with its own URL, use links instead, so they can be opened in a new tab and announced as links. Use Breadcrumbs to show where a page sits, and Button group to switch the view of the same content, such as list and grid.

API reference

Props

Tabs (Radix Root): holds the selected value.

Tabs props
PropTypeDefaultDescription
defaultValuestring—The tab selected at first, when uncontrolled.
valuestring—The selected tab, when controlled. Use with onValueChange.
onValueChange(value: string) => void—Called when a new tab is selected.
activationMode'automatic' | 'manual''automatic'Automatic opens a tab when it's focused; manual waits for Enter or Space.
orientation'horizontal' | 'vertical''horizontal'Sets which arrow keys move focus. Only the horizontal style exists.
dir'ltr' | 'rtl'—Reading direction for arrow keys. Inherits from the page when omitted.
asChildbooleanfalseRadix: merge props onto the single child instead of rendering a div.
refRef<HTMLDivElement>—Forwarded to the root div.
…restHTMLAttributes<HTMLDivElement>—Any other div attribute. className is merged with .nu-tabs.

TabsList: the bar and its tablist.

TabsList props
PropTypeDefaultDescription
actionReactNode—Figma's Button: a control at the end of the bar, rendered outside the tablist. Give it its own accessible name.
barClassNamestring—Class for the outer bar (.nu-tabs-bar), which draws the lines and holds the action.
loopbooleantrueArrow keys wrap from the last tab to the first and back.
asChildbooleanfalseRadix: merge props onto the single child instead of rendering a div.
refRef<HTMLDivElement>—Forwarded to the tablist element, not the outer bar.
…restHTMLAttributes<HTMLDivElement>—Goes on the tablist: aria-label, aria-labelledby, and className (merged with .nu-tabs-list).

TabsTrigger: one tab.

TabsTrigger props
PropTypeDefaultDescription
valuerequiredstring—Links the trigger to the TabsContent with the same value.
leadingIconReactNode—Icon before the label (Figma Left icon, 20px). Rendered aria-hidden.
trailingIconReactNode—Icon after the label (Figma Right icon, 18px in a 20px round frame). Rendered aria-hidden.
countReactNode—Number badge after the label (Figma Number). Part of the accessible name. null, undefined and false hide it; 0 shows.
countLabelstring—Screen-reader text that replaces count in the name, such as “3 to mark”.
disabledbooleanfalseNot in Figma. The tab can’t be selected and the arrow keys skip it.
asChildbooleanfalseRadix: merge props onto the single child instead of rendering a button.
refRef<HTMLButtonElement>—Forwarded to the button.
…restButtonHTMLAttributes—Any other button attribute. className is merged with .nu-tabs-trigger.

TabsContent: the panel for one tab.

TabsContent props
PropTypeDefaultDescription
valuerequiredstring—The trigger value this panel belongs to.
forceMounttrue—Keep the panel mounted while inactive (it’s hidden). Inactive panels are unmounted by default.
asChildbooleanfalseRadix: merge props onto the single child instead of rendering a div.
refRef<HTMLDivElement>—Forwarded to the panel div.
…restHTMLAttributes<HTMLDivElement>—Any other div attribute. className is merged with .nu-tabs-content.

Types are exported as TabsProps, TabsListProps, TabsTriggerProps and TabsContentProps. The Radix props are listed in full in the Radix Tabs reference; Nuton adds only action, barClassName, leadingIcon, trailingIcon, count and countLabel (ADR 0005).

Styling hooks

The parts render .nu-tabs, .nu-tabs-bar (with data-has-action), .nu-tabs-list, .nu-tabs-trigger and .nu-tabs-content. Radix sets data-state="active" or "inactive", data-disabled and data-orientation. Use props rather than styling these; the --nu-tabs-* tokens (see State tokens) are the supported override points.

Accessibility

Keyboard

KeyBehaviour
TabMoves focus to the selected tab, then to the bar action (if any), then into the panel.
← / →Moves to the previous or next tab, wrapping at the ends. Opens it in automatic mode. Disabled tabs are skipped.
Home / EndMoves to the first or last tab.
Enter / SpaceOpens the focused tab in manual mode.

Screen readers

Radix wires the WAI-ARIA tabs pattern: tablist, tab with aria-selected and aria-controls, and tabpanel labelled by its tab. Icons are hidden. The count is read with the label, or replaced by countLabel.

Focus

Only the selected tab is in the tab order (roving focus). Figma has no focus state, so the browser outline is kept, inset 2px (pending a decision, as C10). The panel is focusable so keyboard users can reach content without links.

Contrast and colour

Inactive labels are 6.69:1 and active labels 17.93:1. The active line is 5.59:1, above the 3:1 non-text minimum, so selection isn't shown by colour alone. The count badge is 5.26:1 (Red 700, C6).

Touch and pointer

Each tab is 47px high (the bar below its top line) and its content plus 4px wide, with 20px between tabs. Hover styles apply only on hover-capable pointers. Tabs that don't fit scroll sideways inside the bar.

You must: give TabsList an aria-label or aria-labelledby; give the action its own accessible name; add countLabel when a bare number isn't clear; use links, not tabs, for separate pages.

Figma mapping

Figma propertyFigma valuesCode
Bar: Quantity02 – 06 (plus a 7-item option)The number of TabsTrigger children
Bar: ButtonOff · OnTabsList action (a slot; pass your own Button)
Item: StateDefault · HoverInteractive states, plus disabled (code-only)
Item: ActiveOff · OnThe selected tab (value / defaultValue)
Item: Left Icon · Pick LeftOff · On; layout-grid-lineleadingIcon
Item: Right Icon · Pick RightOff · On; arrow-right-s-linetrailingIcon
Item: NumberOff · On (Badge, Small 16)count, countLabel
Item: Edit TextTextchildren

Nodes: 259:14003 (page), 259:14079 (bar), 259:14119 (item). The bar binds remote 01-Tokens colours; code uses the local twins with the same values (the C19 principle).

Guidelines and related components

Use Tabs for

  • Peer sections of one thing, such as a space's overview, lessons and students.
  • Switching between views people compare, such as this week and this term.
  • Keeping a long page short when people need one section at a time.

Don't use Tabs for

  • Steps in a sequence, such as a sign-up flow. Show progress with Progress instead.
  • Separate pages with their own URLs. Use links.
  • Switching the layout of the same content. Use Button group.

Do and don't

DoUse short, parallel labels. People scan the bar in a glance and know what each panel holds.
Don'tDon't write sentences in tabs. Long labels push tabs off screen and are hard to compare.
DoUse tabs for sections people move between freely. Any order makes sense, and nothing is lost by switching.
Don'tDon't use tabs as steps. Tabs imply people can skip ahead; a sequence needs a flow with Next and Back.
DoSave counts for things that need attention. One badge stands out and tells people where to go next.
Don'tDon't put a count on every tab. Red badges everywhere read as alarms and lose their meaning.

Common mistakes

  • Leaving TabsList without an aria-label, so screen readers announce an unnamed tab list.
  • Giving a trigger and its panel different values, so the panel never shows.
  • Keeping a form draft inside an inactive panel. It unmounts and the draft is lost; lift the state up or use forceMount.
  • Putting buttons or links inside TabsList children. Only triggers belong there; use action for a control.