Open UI

Tabs

Organizes content into multiple panels and lets users switch between them. For page-level navigation, use a nav element or breadcrumbs instead.

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs>      <TabList aria-label="History of Ancient Rome">        <Tab id="FoR">Founding of Rome</Tab>        <Tab id="MaR">Monarchy and Republic</Tab>        <Tab id="Emp">Empire</Tab>      </TabList>      <TabPanel id="FoR">        Arma virumque cano, Troiae qui primus ab oris.      </TabPanel>      <TabPanel id="MaR">Senatus Populusque Romanus.</TabPanel>      <TabPanel id="Emp">Alea jacta est.</TabPanel>    </Tabs>  )}

Usage

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"
<Tabs>
  <TabList aria-label="My sections">
    <Tab id="one">Section One</Tab>
    <Tab id="two">Section Two</Tab>
  </TabList>
  <TabPanel id="one">Content for section one.</TabPanel>
  <TabPanel id="two">Content for section two.</TabPanel>
</Tabs>

Alternatively, install the component as local source via the shadcn CLI:

npx shadcn@latest add https://oui.open.gov.sg/r/tabs.json
pnpm dlx shadcn@latest add https://oui.open.gov.sg/r/tabs.json
npx shadcn@latest add https://oui.open.gov.sg/r/tabs.json
bunx --bun shadcn@latest add https://oui.open.gov.sg/r/tabs.json

Tabs renders a role="tablist" strip of tab triggers and associates each trigger with its role="tabpanel" via ARIA. It is built on React Aria's Tabs and handles keyboard navigation, focus management, selection state, and ARIA semantics automatically.

TabList always needs an aria-label (or aria-labelledby) to identify the tab group for assistive technology.

Anatomy

Tabs consists of:

  • Tabs (the root container; manages selection state and propagates variant context)
    • TabList (the row of tab triggers; renders as role="tablist")
      • Tab (one per tab; renders as role="tab")
    • TabPanel (one per tab; renders as role="tabpanel" — shows content for the selected tab)

Examples

Variants

Two variants are available: underlined (default) and bordered.

import { Tab, TabList, Tabs } from "@opengovsg/oui"export const Example = () => {  const variants = ["underlined", "bordered"] as const  return (    <div className="flex flex-wrap gap-4">      {variants.map((variant) => (        <Tabs key={variant} variant={variant}>          <TabList aria-label="History of Ancient Rome">            <Tab id="FoR">Founding of Rome</Tab>            <Tab id="MaR">Monarchy and Republic</Tab>            <Tab id="Emp">Empire</Tab>          </TabList>        </Tabs>      ))}    </div>  )}

Prominence

The prominence prop controls the visual weight of the tab text. Defaults to "strong" (uppercase, semi-bold). Use "normal" for a lighter appearance.

The prominence prop only has a visible effect with the underlined variant.

import { Tab, TabList, Tabs } from "@opengovsg/oui"export const Example = () => {  const prominences = ["strong", "normal"] as const  return (    <div className="flex flex-wrap gap-4">      {prominences.map((prominence) => (        <Tabs key={prominence} prominence={prominence}>          <TabList aria-label="History of Ancient Rome">            <Tab id="FoR">Founding of Rome</Tab>            <Tab id="MaR">Monarchy and Republic</Tab>            <Tab id="Emp">Empire</Tab>          </TabList>        </Tabs>      ))}    </div>  )}

With Icons

Use startContent or endContent on Tab to place an icon alongside the label.

import { FileText, Home, Settings } from "lucide-react"import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs>      <TabList aria-label="App sections">        <Tab id="home" startContent={<Home className="h-4 w-4" />}>          Home        </Tab>        <Tab id="documents" startContent={<FileText className="h-4 w-4" />}>          Documents        </Tab>        <Tab id="settings" endContent={<Settings className="h-4 w-4" />}>          Settings        </Tab>      </TabList>      <TabPanel id="home">Home content goes here.</TabPanel>      <TabPanel id="documents">Documents content goes here.</TabPanel>      <TabPanel id="settings">Settings content goes here.</TabPanel>    </Tabs>  )}

Default Selection

Use defaultSelectedKey to set the initially selected tab in an uncontrolled component.

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs defaultSelectedKey="keyboard">      <TabList aria-label="Input settings">        <Tab id="mouse">Mouse Settings</Tab>        <Tab id="keyboard">Keyboard Settings</Tab>        <Tab id="gamepad">Gamepad Settings</Tab>      </TabList>      <TabPanel id="mouse">Mouse Settings</TabPanel>      <TabPanel id="keyboard">Keyboard Settings</TabPanel>      <TabPanel id="gamepad">Gamepad Settings</TabPanel>    </Tabs>  )}

Controlled Selection

Use selectedKey and onSelectionChange together to drive selection from external state.

import type { Key } from "react-aria-components"import { useState } from "react"import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  const [timePeriod, setTimePeriod] = useState<Key>("triassic")  return (    <div className="flex flex-col gap-4">      <p>Selected time period: {timePeriod}</p>      <Tabs selectedKey={timePeriod} onSelectionChange={setTimePeriod}>        <TabList aria-label="Mesozoic time periods">          <Tab id="triassic">Triassic</Tab>          <Tab id="jurassic">Jurassic</Tab>          <Tab id="cretaceous">Cretaceous</Tab>        </TabList>        <TabPanel id="triassic">          The Triassic ranges roughly from 252 million to 201 million years ago,          preceding the Jurassic Period.        </TabPanel>        <TabPanel id="jurassic">          The Jurassic ranges from 200 million years to 145 million years ago.        </TabPanel>        <TabPanel id="cretaceous">          The Cretaceous is the longest period of the Mesozoic, spanning from          145 million to 66 million years ago.        </TabPanel>      </Tabs>    </div>  )}

Keyboard Activation

By default, arrow keys immediately change the selected tab. Set keyboardActivation="manual" to require Enter or Space to confirm after navigating with arrows.

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs keyboardActivation="manual">      <TabList aria-label="Input settings">        <Tab id="mouse">Mouse Settings</Tab>        <Tab id="keyboard">Keyboard Settings</Tab>        <Tab id="gamepad">Gamepad Settings</Tab>      </TabList>      <TabPanel id="mouse">Mouse Settings</TabPanel>      <TabPanel id="keyboard">Keyboard Settings</TabPanel>      <TabPanel id="gamepad">Gamepad Settings</TabPanel>    </Tabs>  )}

Dynamic Items

Use the items prop on TabList and a Collection wrapper around TabPanels when the tab list comes from data.

import { useState } from "react"import { Collection } from "react-aria-components"import { Button, Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  const [tabs, setTabs] = useState([    { id: 1, title: "Tab 1", content: "Tab body 1" },    { id: 2, title: "Tab 2", content: "Tab body 2" },    { id: 3, title: "Tab 3", content: "Tab body 3" },  ])  const addTab = () => {    setTabs((tabs) => [      ...tabs,      {        id: tabs.length + 1,        title: `Tab ${tabs.length + 1}`,        content: `Tab body ${tabs.length + 1}`,      },    ])  }  const removeTab = () => {    if (tabs.length > 1) {      setTabs((tabs) => tabs.slice(0, -1))    }  }  return (    <Tabs className="w-full">      <div className="flex gap-2">        <TabList className="flex-1" aria-label="Dynamic tabs" items={tabs}>          {(item) => <Tab>{item.title}</Tab>}        </TabList>        <div className="group flex gap-1">          <Button variant="outline" onPress={addTab}>            Add tab          </Button>          <Button className="shrink-0" variant="outline" onPress={removeTab}>            Remove tab          </Button>        </div>      </div>      <Collection items={tabs}>        {(item) => <TabPanel>{item.content}</TabPanel>}      </Collection>    </Tabs>  )}

Orientation

Set orientation="vertical" to arrange tabs in a column.

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs orientation="vertical">      <TabList aria-label="Chat log orientation example">        <Tab id="1">John Doe</Tab>        <Tab id="2">Jane Doe</Tab>        <Tab id="3">Joe Bloggs</Tab>      </TabList>      <TabPanel id="1">There is no prior chat history with John Doe.</TabPanel>      <TabPanel id="2">There is no prior chat history with Jane Doe.</TabPanel>      <TabPanel id="3">        There is no prior chat history with Joe Bloggs.      </TabPanel>    </Tabs>  )}

Disabled

Set isDisabled on Tabs to disable all tabs, or on an individual Tab to disable a single tab.

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs isDisabled>      <TabList aria-label="Input settings">        <Tab id="mouse">Mouse Settings</Tab>        <Tab id="keyboard">Keyboard Settings</Tab>        <Tab id="gamepad">Gamepad Settings</Tab>      </TabList>      <TabPanel id="mouse">Mouse Settings</TabPanel>      <TabPanel id="keyboard">Keyboard Settings</TabPanel>      <TabPanel id="gamepad">Gamepad Settings</TabPanel>    </Tabs>  )}
import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs>      <TabList aria-label="Input settings">        <Tab id="mouse">Mouse Settings</Tab>        <Tab id="keyboard">Keyboard Settings</Tab>        <Tab id="gamepad" isDisabled>          Gamepad Settings        </Tab>      </TabList>      <TabPanel id="mouse">Mouse Settings</TabPanel>      <TabPanel id="keyboard">Keyboard Settings</TabPanel>      <TabPanel id="gamepad">Gamepad Settings</TabPanel>    </Tabs>  )}

Use disabledKeys on Tabs to disable a set of tabs by their id — convenient for dynamic collections.

import { Collection } from "react-aria-components"import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"export const Example = () => {  const tabs = [    { id: 1, title: "Mouse settings" },    { id: 2, title: "Keyboard settings" },    { id: 3, title: "Gamepad settings" },  ]  return (    <Tabs disabledKeys={[2]}>      <TabList aria-label="Input settings" items={tabs}>        {(item) => <Tab>{item.title}</Tab>}      </TabList>      <Collection items={tabs}>        {(item) => <TabPanel>{item.title}</TabPanel>}      </Collection>    </Tabs>  )}

Focusable Content

When a TabPanel contains no focusable children, the panel itself receives tabIndex=0 so keyboard users can reach it. When the panel contains focusable elements (e.g., an input), the panel is not made focusable — the focusable children handle it.

import { Tab, TabList, TabPanel, Tabs, TextField } from "@opengovsg/oui"export const Example = () => {  return (    <Tabs>      <TabList aria-label="Notes app">        <Tab id="1">Jane Doe</Tab>        <Tab id="2">John Doe</Tab>        <Tab id="3">Joe Bloggs</Tab>      </TabList>      <TabPanel id="1">        <TextField label="Leave a note for Jane" />      </TabPanel>      <TabPanel id="2">Senatus Populusque Romanus.</TabPanel>      <TabPanel id="3">Alea jacta est.</TabPanel>    </Tabs>  )}

Content

Tabs follows the Collection Components API. You can use static or dynamic collections.

Static collections — pass <Tab> children directly when the list is known at render time:

<Tabs>
  <TabList aria-label="Settings">
    <Tab id="general">General</Tab>
    <Tab id="privacy">Privacy</Tab>
  </TabList>
  <TabPanel id="general">General settings.</TabPanel>
  <TabPanel id="privacy">Privacy settings.</TabPanel>
</Tabs>

Dynamic collections — pass an iterable to items on TabList and a render function as children. Wrap TabPanels in a Collection with the same items. Each item object must have an id field (or provide a unique id prop on the rendered element).

import { Collection } from "react-aria-components"
import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"

const tabs = [
  { id: "overview", title: "Overview", content: "Overview content." },
  { id: "details", title: "Details", content: "Details content." },
]

<Tabs>
  <TabList aria-label="Report sections" items={tabs}>
    {(item) => <Tab>{item.title}</Tab>}
  </TabList>
  <Collection items={tabs}>
    {(item) => <TabPanel>{item.content}</TabPanel>}
  </Collection>
</Tabs>

Use textValue on a Tab when its children is not a plain string (e.g., it contains icons) so that keyboard typeahead works correctly.

Events

  • onSelectionChange (on Tabs) — Called when the selected tab changes, receiving the Key of the newly selected tab. Use with selectedKey for controlled mode.
  • onFocus / onBlur (on Tabs) — Called when the tab group receives or loses focus.
  • onKeyDown / onKeyUp (on Tabs) — Called on keyboard events within the tab group.

Accessibility

  • Tabs produces role="tablist", role="tab", and role="tabpanel" elements. Each Tab is linked to its TabPanel via aria-controls and aria-labelledby automatically.
  • TabList must have an aria-label or aria-labelledby so screen readers announce what the tab group controls.
  • Keyboard navigation: Left/Right arrow keys (or Up/Down in vertical orientation) move focus between tabs; Home / End jump to the first/last tab. By default, selection follows focus; set keyboardActivation="manual" to require Enter or Space to select.
  • Tab key: pressing Tab from the tab strip moves focus to the active TabPanel (or its first focusable child). Shift+Tab returns to the active Tab.
  • Disabled tabs receive aria-disabled and are skipped during arrow-key navigation.
  • RTL languages are supported automatically — arrow-key directions are mirrored.

Slots

Slots are named regions of the component you can target with custom Tailwind classes via the classNames prop. Each slot below corresponds to a key on the classNames object.

Tabs uses className directly on each sub-component rather than a single top-level classNames bag, because each sub-component (Tabs, TabList, Tab, TabPanel) accepts its own className prop. Tab's className also accepts a render function for state-based styling.

Tabs root:

  • base: The outer flex container that wraps TabList and TabPanel(s) (rendered as a <div>).

TabList:

  • base: The <div role="tablist"> flex row (or column in vertical orientation) containing all Tab triggers.

Tab:

  • base: The individual <div role="tab"> trigger element. Receives state classes for isSelected, isDisabled, isHovered, isFocusVisible.

TabPanel:

  • base: The <div role="tabpanel"> content area for the currently selected tab.

Custom Styles

import { Tab, TabList, TabPanel, Tabs } from "@opengovsg/oui"import { cn } from "@opengovsg/oui-theme"export const Example = () => {  return (    <Tabs>      <TabList        aria-label="History of Ancient Rome"        className="bg-brand-primary-900 gap-2 rounded-full p-1"      >        <Tab          id="FoR"          className={({ isSelected }) =>            cn(              "rounded-full border-0 p-2 text-sm transition-colors",              isSelected                ? "text-brand-primary-800 bg-white shadow-md"                : "text-brand-primary-200 hover:bg-brand-primary-800 hover:text-white",            )          }        >          Founding of Rome        </Tab>        <Tab          id="MaR"          className={({ isSelected }) =>            cn(              "rounded-full border-0 p-2 text-sm transition-colors",              isSelected                ? "text-brand-primary-800 bg-white shadow-md"                : "text-brand-primary-200 hover:bg-brand-primary-800 hover:text-white",            )          }        >          Monarchy and Republic        </Tab>        <Tab          id="Emp"          className={({ isSelected }) =>            cn(              "rounded-full border-0 p-2 text-sm transition-colors",              isSelected                ? "text-brand-primary-800 bg-white shadow-md"                : "text-brand-primary-200 hover:bg-brand-primary-800 hover:text-white",            )          }        >          Empire        </Tab>      </TabList>      <TabPanel        id="FoR"        className="border-base-divider-medium bg-base-canvas-brand-subtle text-base-content-default mt-2 rounded-lg border p-4"      >        Arma virumque cano, Troiae qui primus ab oris.      </TabPanel>      <TabPanel        id="MaR"        className="border-base-divider-medium bg-base-canvas-brand-subtle text-base-content-default mt-2 rounded-lg border p-4"      >        Senatus Populusque Romanus.      </TabPanel>      <TabPanel        id="Emp"        className="border-base-divider-medium bg-base-canvas-brand-subtle text-base-content-default mt-2 rounded-lg border p-4"      >        Alea jacta est.      </TabPanel>    </Tabs>  )}

Props

Tabs

PropTypeDefaultDescription
childrenReactNode-The TabList and TabPanel elements
selectedKeyKey | null-The currently selected tab key (controlled)
defaultSelectedKeyKey-The initially selected tab key (uncontrolled)
onSelectionChange(key: Key) => void-Called when the selected tab changes
isDisabledbooleanfalseWhether all tabs are disabled
disabledKeysIterable<Key>-Keys of individual tabs to disable
keyboardActivation"automatic" | "manual""automatic"Whether selection follows focus ("automatic") or requires Enter/Space ("manual")
orientation"horizontal" | "vertical""horizontal"The layout direction of the tab list
variant"underlined" | "bordered""underlined"The visual style of tabs
prominence"strong" | "normal""strong"The visual weight of tab text (underlined variant only)
size"xs" | "sm" | "md" | "lg""md"The size of the tabs
classNamestring-Additional class added to the root container

Inherits all remaining props from React Aria's Tabs. Notable ones: id, aria-label, aria-labelledby, autoFocus.

TabList

PropTypeDefaultDescription
childrenReactNode | ((item: T) => ReactNode)-Static Tab children or a render function for dynamic items
itemsIterable<T>-The list of items for a dynamic collection
aria-labelstring-Labels the tab group for assistive technology (required when no visible label exists)
variant"underlined" | "bordered""underlined"Overrides the variant from Tabs context
orientation"horizontal" | "vertical""horizontal"Overrides the orientation from Tabs context
size"xs" | "sm" | "md" | "lg""md"Overrides the size from Tabs context
classNamestring-Additional class added to the tablist element

Inherits all remaining props from React Aria's TabList. Notable ones: aria-labelledby, dependencies.

Tab

PropTypeDefaultDescription
idKey-A unique identifier for the tab; links it to the matching TabPanel
childrenReactNode | ((renderProps: TabRenderProps) => ReactNode)-The tab label content, or a render function receiving render props
textValuestring-Plain-text value for typeahead; required when children is not a plain string
isDisabledbooleanfalseWhether this specific tab is disabled
startContentReactNode-Element rendered to the left of the tab label (e.g., an icon)
endContentReactNode-Element rendered to the right of the tab label (e.g., an icon or badge)
variant"underlined" | "bordered""underlined"Overrides the variant from Tabs context
prominence"strong" | "normal""strong"Overrides the prominence from Tabs context
size"xs" | "sm" | "md" | "lg""md"Overrides the size from Tabs context
classNamestring | ((renderProps: TabRenderProps) => string)-Class or render function for state-based styling

Inherits all remaining props from React Aria's Tab. Notable ones: href, target, rel (router link support).

TabPanel

PropTypeDefaultDescription
idKey-Must match the id of the corresponding Tab
childrenReactNode-The panel content
shouldForceMountbooleanfalseWhether to keep the panel in the DOM when not selected (useful for preserving state)
classNamestring | ((renderProps: TabPanelRenderProps) => string)-Class or render function for state-based styling

Inherits all remaining props from React Aria's TabPanel. Notable ones: aria-label, id.

On this page