Sidebar
A collapsible vertical icon rail. Collapsed, it shows icon-only SidebarItems; open, each item's
label appears next to its icon - rendered away from the anchored edge (a right-anchored rail
shows labels to the left of the icons). The rail renders as a <nav> landmark and requires an
aria-label so multiple navigation regions stay distinguishable.
The open state is purely controlled: pass isOpen and flip it from wherever your layout
keeps the menu toggle - usually a hamburger button in the app bar, not inside the rail. Children
compose freely: SidebarItems, a sponsor logo, a footer, dividers - anything. Items read the
open state and anchor through SidebarContext, so they need no wiring of their own.
Usage
import { Sidebar, SidebarItem } from '@soroush.tech/design-system/Sidebar'
const [isOpen, setIsOpen] = useState(false)
// The toggle lives in your app bar:
<Button aria-label="Expand menu" aria-expanded={isOpen} onClick={() => setIsOpen(!isOpen)}>
<Icon name="menu" />
</Button>
<Sidebar aria-label="Editor panels" anchor="right" isOpen={isOpen} bg="paper">
<SidebarItem icon="folder" label="Directory" isSelected onSelect={openDirectory} />
<SidebarItem icon="terminal" label="Terminal console" onSelect={openTerminal} />
<Typography variant="caption" mt="auto">v1.1.0</Typography>
</Sidebar>
Panel column
With hasPanel, the rail gains a second column and the selected item's children render
there instead of inside its row - an icon rail beside a detail panel. Without it, children
render inline in the row as usual, so the mode is opt-in and changes nothing for existing rails.
<Sidebar aria-label="Editor panels" isOpen={isOpen} hasPanel panelWidth="20rem">
<SidebarItem
icon="folder"
label="Directory"
isSelected={panel === 'directory'}
onSelect={() => setPanel('directory')}
>
<DirectoryTree /> {/* renders in the panel, not in the row */}
</SidebarItem>
</Sidebar>
Selection stays yours - the rail only decides where the selected item's children go. The
selected item ports its children into the panel through Portal, so they stay declared and
mounted inside the item: whatever context, state, and handlers they close over resolve against the
item's own position in the React tree, and the item can sit at any depth in the rail - inside your
own grouping component, a fragment, or a map - with no requirement to be a direct child.
Because the content is ported rather than lifted, it is client-only: Portal renders nothing
during server rendering, so a server-rendered panel arrives empty and fills in on hydration. The
rail and its items server-render as normal.
- The panel column stays mounted as the port target, but collapses while empty (
:empty), so nothing selected - or a selection with no children - leaves no gap. Unnamed while empty too, so an idle panel is not announced as a region. - It is independent of
isOpen, so a collapsed icons-only rail can sit beside an open panel. - It sits on the rail's inner side, following
anchor, and inside the<nav>, so the rail'sbgand padding cover it too rather than stopping at the rail's edge. - The panel is a
<section>named by the item'slabel, and that item becomes a disclosure -aria-expanded, plusaria-controlspointing at the panel while it is shown.
Style or re-tag the column with panelProps - any Flex prop, plus as. It overrides
panelWidth and the derived aria-label; the id and ref stay with the rail, which needs them
as the port target and the aria-controls anchor.
<Sidebar aria-label="Editor panels" isOpen={isOpen} hasPanel
panelProps={{ as: 'aside', bg: 'paper', p: 3, borderLeft: 'thin' }}
>
Examples
Default
<Sidebar isOpen={false} anchor="left" aria-label="Editor panels" />
Open
<Sidebar isOpen anchor="left" aria-label="Editor panels" />
Right Anchored
<Sidebar isOpen anchor="right" aria-label="Editor panels" />
Tinted
<Sidebar isOpen anchor="left" bg="paper" aria-label="Editor panels" />
With Panel
const args: Partial<SidebarProps> = { isOpen: false, anchor: 'left', hasPanel: true, 'aria-label': 'Editor panels' }
<PanelDemo {...args} />
Panel Open Rail
const args: Partial<SidebarProps> = { isOpen: true, anchor: 'left', hasPanel: true, 'aria-label': 'Editor panels' }
<PanelDemo {...args} />
Panel Right Anchored
const args: Partial<SidebarProps> = { isOpen: true, anchor: 'right', hasPanel: true, 'aria-label': 'Editor panels' }
<PanelDemo {...args} />
Panel Styled
const args: Partial<SidebarProps> = {
isOpen: true,
anchor: 'left',
hasPanel: true,
bg: 'grid',
'aria-label': 'Editor panels',
}
<PanelDemo {...args} />