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...
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>
Navigation rail
layout="stacked" makes the rail a navigation rail, as in Material 3: each item's label is under its
icon, always shown, in a rail of one width (stackedWidth, 5.5rem) that does not animate. isOpen
has nothing to do and is ignored. The selected item's fill is on a pill behind its icon, and a
hovered item tints its pill. The default, layout="inline", is the collapsible rail described
above and does not change.
<Sidebar aria-label="Primary" layout="stacked">
<SidebarItem icon="home" label="Home" href="/" aria-current="page" aria-pressed={undefined} />
<SidebarItem icon="search" label="Search" href="/search" aria-pressed={undefined} />
</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.
A panel rendered in place
hasPanel fills the panel on the client: the selected item's children are ported in through
Portal, so the markup the server renders has an empty panel. For a panel the page already knows -
a navigation tree beside a navigation rail - pass it as panel. It is rendered in place, so it is in
the server-rendered markup and works with JavaScript off. panel takes the place of hasPanel:
with both, the items keep their children in their rows.
<Sidebar
aria-label="Primary"
layout="stacked"
panel={<NavTree>...</NavTree>}
panelProps={{ 'aria-label': 'Components', bg: 'secondary' }}
>
<SidebarItem icon="add_circle" label="Components" href="/components/" aria-pressed={undefined} />
</Sidebar>
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', panelProps: { as: 'aside', bg: 'paper', width: '22rem', borderLeft: 'thin' }, } <PanelDemo {...args} />
Navigation Rail
const args: Partial<SidebarProps> = { isOpen: false, anchor: 'left', layout: 'stacked', bg: 'paper', 'aria-label': 'Editor panels', } <Sidebar {...args} aria-label="Primary"> <SidebarItem icon="home" label="Home" href="#home" isSelected aria-current="page" aria-pressed={undefined} /> <SidebarItem icon="grid_view" label="Get started" href="#start" aria-pressed={undefined} /> <SidebarItem icon="add_circle" label="Components" href="#components" aria-pressed={undefined} /> <SidebarItem icon="inventory_2" label="Packages" href="#packages" aria-pressed={undefined} /> <SidebarItem icon="palette" label="Styles" href="#styles" aria-pressed={undefined} /> </Sidebar>
Navigation Rail With Panel
const args: Partial<SidebarProps> = { isOpen: false, anchor: 'left', layout: 'stacked', bg: 'paper', 'aria-label': 'Editor panels', } <Sidebar {...args} aria-label="Primary" panelProps={{ 'aria-label': 'Components', bg: 'secondary', p: 2 }} panel={ <Flex flexDirection="column" gap={1}> <Typography variant="subtitle2" m={0}> Components </Typography> <Typography variant="body2" m={0}> Button </Typography> <Typography variant="body2" m={0}> Tabs </Typography> </Flex> } > <SidebarItem icon="home" label="Home" href="#home" aria-pressed={undefined} /> <SidebarItem icon="add_circle" label="Components" href="#components" isSelected aria-current="page" aria-pressed={undefined} /> <SidebarItem icon="palette" label="Styles" href="#styles" aria-pressed={undefined} /> </Sidebar>