Design system

Tabs

Underlined tabs that switch between sections of a page.

import { Tabs } from "@/ui";

Tabs matches the Figma Tabs component: three sizes, a regular and an extended length, and Default, Hover, Active and Disabled states. A tab can have an icon before or after its label, a count, or only an icon.

Unselected tabs have a 1px --color-grey-100 underline and --color-text-primary text, and turn blue on hover. The selected tab has a 2px --color-primary-500 underline and blue medium text. The underline is drawn inside the tab, so the height is the same in every state.

Basic tabs

Tabs are controlled. Keep activeIndex in state and update it in onTabChange; the event's index is the tab that was clicked. Only the selected tab's content is rendered, below the tab list.

const [active, setActive] = useState(0);

<Tabs
  activeIndex={active}
  onTabChange={(e) => setActive(e.index)}
  tabData={[
    { label: "Overview", content: <CampaignOverview /> },
    { label: "Campaigns", content: <CampaignList /> },
    { label: "Pipeline", content: <PipelineSummary /> },
  ]}
/>

Icons and counts

prefixIcon puts a Phosphor icon before the label and count adds a small count after it. The count is grey on unselected tabs and blue with white text on the selected tab. Icons take the text colour.

import { ChartBar, Megaphone, Funnel, Table } from "@phosphor-icons/react";

tabData={[
  { label: "Summary", prefixIcon: ChartBar },
  { label: "Campaigns", prefixIcon: Megaphone, count: 14 },
  { label: "Funnel", prefixIcon: Funnel },
  { label: "Raw data", prefixIcon: Table, count: 3 },
]}

suffixIcon puts the icon after the label instead. Figma's examples show both at once, but use one icon per tab.

tabData={[
  { label: "All accounts", suffixIcon: CaretDown },
  { label: "Saved views", suffixIcon: CaretDown, count: 4 },
]}

Pass icon components, not elements (ChartBar, not <ChartBar />). Tabs sets their size.

Sizes

<Tabs size="small" ... />
<Tabs size="medium" ... />
<Tabs size="large" ... />
SizeTextIconHeight
small12px / 16px12px32px
medium (default)12px / 19px12px35px
large14px / 22px16px38px

Every size has 8px top and bottom padding, 16px side padding and a 10px gap between the icon, label and count. The count is 16px tall with 12px text.

Figma's Small tabs and the count use 10px text. We use 12px, since no product text goes below 12px. Small tabs keep their 32px height.

Extended

extended doubles the side padding to 32px, for a short tab list that should take up more of the bar.

<Tabs extended activeIndex={active} onTabChange={(e) => setActive(e.index)} tabData={tabData} />

Icon only

Set iconOnly on a tab to show its icon without the label. The label is still required: it becomes the tab's accessible name and its tooltip.

tabData={[
  { label: "Chart", prefixIcon: ChartBar, iconOnly: true },
  { label: "Table", prefixIcon: Table, iconOnly: true },
  { label: "List", prefixIcon: ListBullets, iconOnly: true, count: 2 },
]}

An icon-only tab is 44px wide at small and medium and 48px at large. Use it only when the icons are unambiguous, like a chart and table switch.

Disabled

Set disabled on one tab to stop it from being picked, or on Tabs to disable every tab. Disabled tabs have --color-text-disabled text and a grey-50 count, and can't be clicked or reached with the arrow keys. If the selected tab is disabled, its underline turns --color-grey-300.

tabData={[
  { label: "Overview" },
  { label: "Campaigns", count: 14 },
  { label: "Settings", prefixIcon: Gear, disabled: true },
]}

<Tabs disabled ... />

When the tabs switch routes rather than panels, leave out content and navigate in onTabChange, as in a page header.

<Tabs
  activeIndex={isWorkstreams ? 1 : 0}
  onTabChange={(e) => navigate(e.index === 0 ? "/home" : "/home/workstreams")}
  tabData={[{ label: "Home" }, { label: "Workstreams" }]}
  size="large"
  navClassName="px-8 h-[52px]"
  showDefaultBottomBorder={false}
/>

showDefaultBottomBorder={false} removes the grey underline from unselected tabs, which suits a header that already has its own bottom border. Tabs stretch to the height of the tab list, so the underline sits on the header's edge.

Props

PropTypeDefaultNotes
tabDataTab[][]Nothing renders when it is empty.
activeIndexnumber | null0null selects no tab, for a page that belongs to none of them.
onTabChange(e: { originalEvent, index }) => void
size"small" | "medium" | "large""medium"
extendedbooleanfalse32px side padding.
disabledbooleanfalseDisables every tab.
showDefaultBottomBorderbooleantrueGrey underline on unselected tabs.
navClassNamestringClasses for the tab list.
headerClassNamestringClasses for each tab.
classNamestringClasses for the root. Other props are passed to the root too.

Each Tab:

FieldTypeNotes
labelstringRequired, also for icon-only tabs.
contentReactNodeShown below the list when the tab is selected.
prefixIconElementTypeIcon before the label.
suffixIconElementTypeIcon after the label.
countnumber | stringCount after the label.
iconOnlybooleanHides the label. Needs prefixIcon.
disabledboolean

Differences from Figma

  • Small tab and count text is 12px, not 10px.
  • In Figma, the Hover and Disabled tabs have no grey underline. Here they keep it, so the line under a tab list doesn't break when you hover a tab.
  • Figma's disabled Small tab without a count has 16px icons. Here every Small tab has 12px icons, as in the rest of the Small set.
  • Figma has no disabled selected tab. Here its underline is grey-300.
  • Icon-only Small and Medium tabs are 28px tall in Figma, shorter than the 32px and 35px text tabs. Don't mix icon-only and text tabs in one list.

Accessibility

  • The list is role="tablist" and each tab is a button with role="tab" and aria-selected. When tabs have content, the panel is role="tabpanel" and labelled by its tab.
  • Tab moves focus to the selected tab. Left and Right arrows move between tabs, Home and End jump to the first and last, and Enter or Space selects (manual activation, so arrowing through route tabs doesn't navigate).
  • Disabled tabs use the disabled attribute, so they are skipped by the keyboard and announced as unavailable.
  • Icon-only tabs use label as their aria-label.

Do and don't

  • Do use Tabs for two to five peer sections of one page or one object, such as a campaign's Overview, Creatives and Pipeline.
  • Do keep labels to one or two words in sentence case.
  • Do use a count when it helps someone pick a tab, like the number of items in each.
  • Don't use Tabs for a small switch inside a card or panel. Use TabToggle there.
  • Don't put actions such as "New campaign" in the tab list. Put them in the page header.

On this page