Sidebar
Displays a list of links or actions to navigate between different sections.
import { Calendar, Link, MailIcon, Star } from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { type: "header", children: "Mail" }, { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Go to Inbox", }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Go to Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Go to Activity", }, { children: "Explore", startContent: <Link />, onPress: () => alert("Explore clicked"), tooltip: "Go to Explore", },]export const Example = () => { return <Sidebar items={items} />}Usage
OUI exports 5 sidebar-related components:
- Sidebar: A high-level convenience component that auto-generates items from a data array.
- SidebarRoot: The base container component that provides context and styling to all children.
- SidebarItem: An individual navigation item rendered as a link.
- SidebarList: A collapsible section that can contain nested items.
- SidebarHeader: A non-interactive header element used for section titles.
import { Sidebar } from "@opengovsg/oui"<Sidebar
items={[
{ type: "header", children: "Section" },
{
startContent: <MailIcon />,
onPress: () => alert("Inbox clicked"),
children: "Inbox",
tooltip: "Inbox",
},
{
label: "Settings",
startContent: <Wrench />,
subItems: [
{
children: "Profile",
onPress: () => alert("Profile clicked"),
tooltip: "Profile",
},
],
},
]}
/>Alternatively, install the component as local source via the shadcn CLI:
npx shadcn@latest add https://oui.open.gov.sg/r/sidebar.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/sidebar.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/sidebar.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/sidebar.jsonThere are two ways to use the sidebar:
- Data-driven (recommended): Pass an
itemsarray to theSidebarcomponent. This handles rendering and nesting automatically. - Composition: Use
SidebarRoot,SidebarItem,SidebarList, andSidebarHeaderdirectly for full control over the structure.
Features
- Accessible – Built on React Aria primitives (
Link,Disclosure,Tooltip) with proper ARIA labels and keyboard navigation. - Collapsible – The entire sidebar can collapse to show only icons, with tooltips appearing on hover for accessibility.
- Expandable sections –
SidebarListsections can independently expand and collapse, with support for both controlled and uncontrolled state. - Flexible – Use the high-level
Sidebarcomponent for quick setup, or compose individual components for custom layouts. - Internationalized – Expand/collapse labels are localized for supported locales.
Examples
Navigation
Each SidebarItem is built on React Aria's Link component, so it accepts all Link props for navigation. Use href for standard URL navigation, or onPress for custom click handling.
// URL navigation
<SidebarItem href="/inbox" startContent={<MailIcon />}>
Inbox
</SidebarItem>
// Custom press handler
<SidebarItem onPress={() => alert("Inbox clicked")} startContent={<MailIcon />}>
Inbox
</SidebarItem>When using the data-driven Sidebar component, pass these props directly in the items array:
const items = [
{ children: "Inbox", href: "/inbox", startContent: <MailIcon /> },
{
children: "Starred",
onPress: () => navigate("/starred"),
startContent: <Star />,
},
]Sizes
Use the size prop to change the density of the sidebar. The default size is md.
import { Calendar, MailIcon, Star } from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Go to Inbox", }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Go to Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Go to Activity", },]export const Example = () => { return ( <div className="flex gap-8"> <div> <p className="prose-body-2 text-base-content-medium mb-2"> Medium (default) </p> <Sidebar items={items} size="md" /> </div> <div> <p className="prose-body-2 text-base-content-medium mb-2">Small</p> <Sidebar items={items} size="sm" /> </div> </div> )}Composition
For full control over the sidebar structure, use the individual components directly instead of the items array.
import {
SidebarHeader,
SidebarItem,
SidebarList,
SidebarRoot,
} from "@opengovsg/oui"import { Calendar, MailIcon, Star, Wrench } from "lucide-react"import { SidebarHeader, SidebarItem, SidebarList, SidebarRoot,} from "@opengovsg/oui"export const Example = () => { return ( <SidebarRoot> <SidebarHeader>Mail</SidebarHeader> <SidebarItem isSelected startContent={<MailIcon />} onPress={() => alert("Inbox clicked")} tooltip="Go to Inbox" > Inbox </SidebarItem> <SidebarItem startContent={<Star />} onPress={() => alert("Starred clicked")} tooltip="Go to Starred" > Starred </SidebarItem> <SidebarItem startContent={<Calendar />} onPress={() => alert("Activity clicked")} tooltip="Go to Activity" > Activity </SidebarItem> <SidebarList defaultIsExpanded label="Settings" startContent={<Wrench />}> <SidebarItem onPress={() => alert("General clicked")} tooltip="Go to General" > General </SidebarItem> <SidebarItem onPress={() => alert("Security clicked")} tooltip="Go to Security" > Security </SidebarItem> </SidebarList> </SidebarRoot> )}Collapsible Sections
Use SidebarList items (or objects with subItems in the items array) to create expandable sections. Set defaultIsExpanded to control the initial expansion state.
import { Clock5, Trash, User, Wrench } from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { label: "Settings", startContent: <Wrench />, defaultIsExpanded: true, subItems: [ { tooltip: "Go to Profile", startContent: <User />, children: "Profile", onPress: () => alert("Profile clicked"), }, { tooltip: "Go to Security & Privacy", children: "Security & Privacy", startContent: <Trash />, onPress: () => alert("Security & Privacy clicked"), isSelected: true, }, { tooltip: "Go to Notifications", children: "Notifications", startContent: <Clock5 />, onPress: () => alert("Notifications clicked"), }, ], }, { label: "Account", startContent: <User />, subItems: [ { tooltip: "Go to Billing", children: "Billing", onPress: () => alert("Billing clicked"), }, { tooltip: "Go to Usage", children: "Usage", onPress: () => alert("Usage clicked"), }, ], },]export const Example = () => { return <Sidebar items={items} />}Only Caret Toggle
By default, clicking anywhere on a SidebarList item toggles its expansion. Set onlyCaretToggle to true to restrict toggling to only the caret icon. This is useful when the section label itself should act as a navigable link.
When using
onlyCaretToggle, pass navigation props (such ashref) directly to theSidebarListcomponent. The label becomes aLinkelement while the caret remains a separate toggle button.
import { MailIcon, Star, Wrench } from "lucide-react"import { SidebarItem, SidebarList, SidebarRoot } from "@opengovsg/oui"export const Example = () => { return ( <SidebarRoot> <SidebarItem startContent={<MailIcon />} onPress={() => alert("Inbox clicked")} tooltip="Go to Inbox" > Inbox </SidebarItem> <SidebarList onlyCaretToggle label="Clicking this will not expand/collapse the section" startContent={<Wrench />} onPress={() => alert( "onPress/href will be triggered instead. Click the caret to expand/collapse.", ) } > <SidebarItem onPress={() => alert("General clicked")} tooltip="Go to General" > General </SidebarItem> <SidebarItem onPress={() => alert("Security clicked")} tooltip="Go to Security" > Security </SidebarItem> </SidebarList> <SidebarList defaultIsExpanded label="Favorites" startContent={<Star />}> <SidebarItem onPress={() => alert("Dashboard clicked")} tooltip="Go to Dashboard" > Dashboard </SidebarItem> <SidebarItem onPress={() => alert("Reports clicked")} tooltip="Go to Reports" > Reports </SidebarItem> </SidebarList> </SidebarRoot> )}Selected State
Use the isSelected prop on items to indicate the currently active navigation item. Top-level selected items receive a background highlight, while nested selected items display a left border accent instead.
isSelected accepts either a boolean or a () => boolean function, which is useful for dynamic route matching.
// Static
<SidebarItem isSelected>Active item</SidebarItem>
// Dynamic
<SidebarItem isSelected={() => pathname === "/inbox"}>Inbox</SidebarItem>import { Calendar, MailIcon, Star, User, Wrench } from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Go to Inbox", isSelected: true, }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Go to Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Go to Activity", }, { label: "Settings", startContent: <Wrench />, defaultIsExpanded: true, subItems: [ { tooltip: "Go to Profile", startContent: <User />, children: "Profile", onPress: () => alert("Profile clicked"), }, ], },]export const Example = () => { return <Sidebar items={items} />}Collapsed
Set isCollapsed to true to collapse the sidebar to show only icons. When collapsed:
- Text labels and
endContentare hidden. - Headers are hidden.
- Section expand/collapse is disabled.
- Tooltips appear on hover using each item's
tooltipprop for accessibility.
Every item should have a
tooltipprop set when the sidebar supports collapsing. This ensures users can still identify each item when labels are hidden.
import { Calendar, Clock5, Link, MailIcon, Star, Trash, User, Wrench,} from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { type: "header", children: "Mail" }, { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Inbox", }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Activity", }, { children: "Explore", startContent: <Link />, onPress: () => alert("Explore clicked"), tooltip: "Explore", }, { label: "Settings", startContent: <Wrench />, defaultIsExpanded: true, subItems: [ { tooltip: "Profile", startContent: <User />, children: "Profile", onPress: () => alert("Profile clicked"), }, { tooltip: "Security & Privacy", children: "Security & Privacy", startContent: <Trash />, onPress: () => alert("Security & Privacy clicked"), }, { tooltip: "Notifications", children: "Notifications", startContent: <Clock5 />, onPress: () => alert("Notifications clicked"), }, ], },]export const Example = () => { return <Sidebar items={items} isCollapsed />}Customizing Tooltips
Use the tooltipProps and tooltipTriggerProps props on SidebarRoot (or Sidebar) to customise tooltip behavior globally for all items. These props are spread onto the underlying React Aria Tooltip and TooltipTrigger components respectively.
By default, collapsed sidebar tooltips use placement="right", offset={4}, and delay={0}.
import { Calendar, Link, MailIcon, Star } from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { type: "header", children: "Mail" }, { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Inbox", }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Activity", }, { children: "Explore", startContent: <Link />, onPress: () => alert("Explore clicked"), tooltip: "Explore", },]export const Example = () => { return ( <Sidebar items={items} isCollapsed tooltipProps={{ placement: "right", offset: 8, className: "bg-base-canvas-default prose-label-3 text-base-content-strong rounded-md border px-2.5 py-1.5 shadow-md", }} tooltipTriggerProps={{ delay: 200 }} /> )}Controlled Collapse
Use isCollapsed and onCollapsedChange together for controlled collapse state. This lets you manage the collapsed state externally, for example with a toggle button.
import { useState } from "react"import { Calendar, Link, MailIcon, PanelLeftClose, PanelLeftOpen, Star,} from "lucide-react"import type { SidebarProps } from "@opengovsg/oui"import { Button, Sidebar } from "@opengovsg/oui"const items: SidebarProps["items"] = [ { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Inbox", }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Activity", }, { children: "Explore", startContent: <Link />, onPress: () => alert("Explore clicked"), tooltip: "Explore", },]export const Example = () => { const [isCollapsed, setIsCollapsed] = useState(false) return ( <div className="flex gap-4"> <div className="flex flex-col gap-2"> <Button variant="outline" size="sm" onPress={() => setIsCollapsed((prev) => !prev)} aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"} > {isCollapsed ? <PanelLeftOpen /> : <PanelLeftClose />} </Button> <Sidebar items={items} isCollapsed={isCollapsed} onCollapsedChange={setIsCollapsed} /> </div> </div> )}Sidebar in a Drawer
On mobile layouts, you can render the sidebar inside a Modal to create a drawer-style navigation panel. Use classNames to override the modal's positioning so it slides in from the side.
import { Calendar, Link, MailIcon, Menu, Star, User, Wrench,} from "lucide-react"import { DialogTrigger } from "react-aria-components"import type { SidebarProps } from "@opengovsg/oui"import { Button, Modal, ModalBody, ModalContent, ModalHeader, Sidebar,} from "@opengovsg/oui"const items: SidebarProps["items"] = [ { type: "header", children: "Mail" }, { startContent: <MailIcon />, onPress: () => alert("Inbox clicked"), children: "Inbox", tooltip: "Go to Inbox", isSelected: true, }, { children: "Starred", startContent: <Star />, onPress: () => alert("Starred clicked"), tooltip: "Go to Starred", }, { children: "Activity", startContent: <Calendar />, onPress: () => alert("Activity clicked"), tooltip: "Go to Activity", }, { children: "Explore", startContent: <Link />, onPress: () => alert("Explore clicked"), tooltip: "Go to Explore", }, { label: "Settings", startContent: <Wrench />, defaultIsExpanded: true, subItems: [ { tooltip: "Go to Profile", startContent: <User />, children: "Profile", onPress: () => alert("Profile clicked"), }, ], },]export const Example = () => { return ( <DialogTrigger> <Button variant="outline" aria-label="Open navigation"> <Menu /> Menu </Button> <Modal isDismissable animation="slide-start" placement="start" classNames={{ base: "m-0 h-full max-h-full max-w-70 rounded-none sm:mx-0 sm:my-0", }} > <ModalContent> <ModalHeader>Navigation</ModalHeader> <ModalBody className="px-0"> <Sidebar items={items} size="sm" /> </ModalBody> </ModalContent> </Modal> </DialogTrigger> )}Slots
SidebarRoot / Sidebar
- base: The root
<nav>element. - ul: The
<ul>list container.
SidebarItem
- item: The
<li>element wrapping each item. - label: The
<Link>element containing the item content.
SidebarList
- list: The
<li>element wrapping the section. - section: The
Disclosurewrapper. - item: The trigger element for expand/collapse.
- label: The label content area.
- chevron: The chevron icon.
- chevronContainer: The wrapper around the chevron button.
- nestedPanel: The
DisclosurePanelcontaining nested items.
SidebarHeader
- headerLi: The
<li>element wrapping the header. - header: The
<h2>element.
All components accept a classNames prop (via SidebarRoot) to override styles for specific slots:
<SidebarRoot
classNames={{
base: "custom-nav",
item: "custom-item",
label: "custom-label",
}}
>
{/* ... */}
</SidebarRoot>API Reference
Sidebar
The Sidebar component is a high-level wrapper that generates items from a data array. It accepts all SidebarRoot props plus the following:
| Prop | Type | Default | Description |
|---|---|---|---|
items | GeneratedSidebarItem[] | - | Array of item definitions to render. |
Each item in the array can be one of three types:
Regular item – Rendered as SidebarItem:
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The item label. |
startContent | ReactNode | - | Content before the label (typically an icon). |
endContent | ReactNode | - | Content after the label. |
tooltip | string | - | Tooltip shown when the sidebar is collapsed. |
isSelected | boolean | () => boolean | - | Whether the item is currently selected. |
href | string | - | Navigation URL (from React Aria LinkProps). |
onPress | () => void | - | Press handler (from React Aria LinkProps). |
Header item – Rendered as SidebarHeader:
| Prop | Type | Default | Description |
|---|---|---|---|
type | "header" | - | Discriminator to indicate a header item. |
children | ReactNode | - | The header text. |
startContent | ReactNode | - | Content before the header text. |
endContent | ReactNode | - | Content after the header text. |
List item – Rendered as SidebarList:
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | - | The section label. |
subItems | GeneratedSidebarItem[] | - | Nested items (can contain any item type, including more lists). |
startContent | ReactNode | - | Content before the label. |
endContent | ReactNode | - | Content after the label. |
tooltip | string | - | Tooltip shown when the sidebar is collapsed. |
isSelected | boolean | () => boolean | - | Whether the section is currently selected. |
defaultIsExpanded | boolean | false | Initial expansion state (uncontrolled). |
isExpanded | boolean | - | Expansion state (controlled). |
onExpand | (isExpanded: boolean) => void | - | Handler called when expansion state changes. |
onlyCaretToggle | boolean | false | Only allow toggling via the caret icon. |
SidebarRoot
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "md" | The size variant. |
isCollapsed | boolean | - | Whether the sidebar is collapsed (controlled). |
defaultCollapsed | boolean | false | Whether the sidebar is collapsed by default. |
onCollapsedChange | (isCollapsed: boolean) => void | - | Handler called when the collapsed state changes. |
tooltipProps | Partial<TooltipProps> | - | Props spread onto each item's Tooltip when collapsed. |
tooltipTriggerProps | Partial<TooltipTriggerComponentProps> | - | Props spread onto each item's TooltipTrigger when collapsed. |
className | string | - | Custom class for the root <nav> element. |
classNames | SlotsToClasses<SidebarSlots> | - | Custom classes for individual slots. |
children | ReactNode | - | Sidebar content (items, headers, lists). |
setCollapsedandisNestedare internal context values (SidebarCollapseContextandSidebarNestContext) used for cross-component communication. They are not public props on any component and are not intended for direct use.
SidebarItem
Extends React Aria's Link props.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The item label. |
startContent | ReactNode | - | Content before the label (typically an icon). |
endContent | ReactNode | - | Content after the label. |
tooltip | string | - | Tooltip shown when the sidebar is collapsed. |
isSelected | boolean | () => boolean | - | Whether the item is currently selected. |
SidebarList
| Prop | Type | Default | Description |
|---|---|---|---|
label | ReactNode | - | The section label displayed in the trigger. |
children | ReactNode | - | Nested items rendered inside the collapsible panel. |
startContent | ReactNode | - | Content before the label. |
endContent | ReactNode | - | Content after the label. |
tooltip | string | - | Tooltip shown when the sidebar is collapsed. |
isSelected | boolean | () => boolean | - | Whether the section is currently selected. |
defaultIsExpanded | boolean | false | Initial expansion state (uncontrolled). |
isExpanded | boolean | - | Expansion state (controlled). |
onExpand | (isExpanded: boolean) => void | - | Handler called when expansion state changes. |
onlyCaretToggle | boolean | false | Restrict expand/collapse to only the caret icon. |
linkProps | LinkProps | - | Props for the link element (only used with onlyCaretToggle). |
SidebarHeader
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The header text. |
startContent | ReactNode | - | Content before the header text. |
endContent | ReactNode | - | Content after the header text. |
Accessibility
The Sidebar component follows accessible navigation patterns:
- Built on React Aria's
LinkandDisclosurecomponents with proper ARIA semantics. - The root element renders as a
<nav>for landmark navigation. - Collapsible sections use
aria-expandedto communicate their state. - When collapsed, each item uses its
tooltipprop asaria-labelto maintain screen reader accessibility. - Keyboard navigation is fully supported:
- Tab: Move focus between sidebar items.
- Enter or Space: Activate links or toggle section expansion.
- Expand/collapse labels are internationalized for supported locales (en-SG, zh-SG, ms-SG, ta-SG).