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.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/tabs.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/tabs.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/tabs.jsonTabs 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")
- Tab (one per tab; renders as
- TabPanel (one per tab; renders as
role="tabpanel"— shows content for the selected tab)
- TabList (the row of tab triggers; renders as
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
prominenceprop only has a visible effect with theunderlinedvariant.
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(onTabs) — Called when the selected tab changes, receiving theKeyof the newly selected tab. Use withselectedKeyfor controlled mode.onFocus/onBlur(onTabs) — Called when the tab group receives or loses focus.onKeyDown/onKeyUp(onTabs) — Called on keyboard events within the tab group.
Accessibility
- Tabs produces
role="tablist",role="tab", androle="tabpanel"elements. EachTabis linked to itsTabPanelviaaria-controlsandaria-labelledbyautomatically. TabListmust have anaria-labeloraria-labelledbyso 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 activeTab. - Disabled tabs receive
aria-disabledand 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 wrapsTabListandTabPanel(s) (rendered as a<div>).
TabList:
base: The<div role="tablist">flex row (or column in vertical orientation) containing allTabtriggers.
Tab:
base: The individual<div role="tab">trigger element. Receives state classes forisSelected,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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The TabList and TabPanel elements |
selectedKey | Key | null | - | The currently selected tab key (controlled) |
defaultSelectedKey | Key | - | The initially selected tab key (uncontrolled) |
onSelectionChange | (key: Key) => void | - | Called when the selected tab changes |
isDisabled | boolean | false | Whether all tabs are disabled |
disabledKeys | Iterable<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 |
className | string | - | 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
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T) => ReactNode) | - | Static Tab children or a render function for dynamic items |
items | Iterable<T> | - | The list of items for a dynamic collection |
aria-label | string | - | 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 |
className | string | - | Additional class added to the tablist element |
Inherits all remaining props from React Aria's TabList. Notable ones: aria-labelledby, dependencies.
Tab
| Prop | Type | Default | Description |
|---|---|---|---|
id | Key | - | A unique identifier for the tab; links it to the matching TabPanel |
children | ReactNode | ((renderProps: TabRenderProps) => ReactNode) | - | The tab label content, or a render function receiving render props |
textValue | string | - | Plain-text value for typeahead; required when children is not a plain string |
isDisabled | boolean | false | Whether this specific tab is disabled |
startContent | ReactNode | - | Element rendered to the left of the tab label (e.g., an icon) |
endContent | ReactNode | - | 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 |
className | string | ((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
| Prop | Type | Default | Description |
|---|---|---|---|
id | Key | - | Must match the id of the corresponding Tab |
children | ReactNode | - | The panel content |
shouldForceMount | boolean | false | Whether to keep the panel in the DOM when not selected (useful for preserving state) |
className | string | ((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.
TableWIP
Tables are used to display tabular data using rows and columns. They allow users to quickly scan, sort, compare, and take action on large amounts of data.
TagField
A type-ahead input for selecting multiple items from a list, rendering each as a removable tag. For single-select with filtering, use ComboBox.