Sidebar
A desktop app sidebar that collapses to a rail of icons and stays usable when collapsed. On small screens, show the same destinations in a Drawer instead; see the responsive navigation recipe.
Code
import {Sidebar} from '@earendil-works/elanor-react';
import {WorkspaceSidebar} from './workspace-sidebar.tsx';
import styles from './sidebar.module.css';
export default function SidebarDemo() {
return (
<div className={styles.frame}>
<Sidebar.Root className={styles.sidebar} render={<aside aria-label="Workspace sidebar" />}>
{({collapsed}) => <WorkspaceSidebar collapsed={collapsed} />}
</Sidebar.Root>
<div className={styles.main}>
Collapse the sidebar with the button next to the workspace name.
</div>
</div>
);
} import {
ChatIcon,
DataExplorationIcon,
GroupsIcon,
HomeIcon,
KeyIcon,
SettingsIcon,
} from '@earendil-works/elanor-icons-react';
import {Link, SideNavigation, Sidebar, Stack} from '@earendil-works/elanor-react';
import {useState, type ReactNode} from 'react';
const groups = [
{
label: 'Insights',
items: [
{label: 'Explore', icon: <DataExplorationIcon />},
{label: 'Sessions', icon: <ChatIcon />},
],
},
{
label: 'Manage',
items: [
{label: 'API keys', icon: <KeyIcon />},
{label: 'Members', icon: <GroupsIcon />},
{label: 'Settings', icon: <SettingsIcon />},
],
},
];
/** Sidebar regions shared by the demos. `collapsed` comes from Sidebar.Root. */
export function WorkspaceSidebar({collapsed}: {readonly collapsed: boolean}) {
const [current, setCurrent] = useState('Home');
const variant = collapsed ? 'compact' : 'default';
const link = (label: string, icon: ReactNode) => (
<SideNavigation.Item key={label}>
<SideNavigation.Link
href="#"
iconStart={icon}
active={label === current}
onClick={(event) => {
event.preventDefault();
setCurrent(label);
}}
>
{label}
</SideNavigation.Link>
</SideNavigation.Item>
);
return (
<>
<Sidebar.Header>
<Stack
direction={collapsed ? 'column' : 'row'}
gap={collapsed ? 'small' : 'auto'}
align="center"
style={{inlineSize: '100%'}}
>
<Link href="#" aria-label="Acme workspace">
{collapsed ? 'A' : 'Acme workspace'}
</Link>
<Sidebar.Trigger />
</Stack>
</Sidebar.Header>
<Sidebar.Content>
<SideNavigation.Root aria-label="Workspace" variant={variant}>
<SideNavigation.List>{link('Home', <HomeIcon />)}</SideNavigation.List>
{groups.map((group) => (
<SideNavigation.Group key={group.label}>
<SideNavigation.GroupLabel>{group.label}</SideNavigation.GroupLabel>
<SideNavigation.List>
{group.items.map((item) => link(item.label, item.icon))}
</SideNavigation.List>
</SideNavigation.Group>
))}
</SideNavigation.Root>
</Sidebar.Content>
<Sidebar.Footer>
<SideNavigation.Root aria-label="Account" variant={variant}>
<SideNavigation.List>
<SideNavigation.Item>
<SideNavigation.Link href="#" compactContent="AM">
Alex Morgan
</SideNavigation.Link>
</SideNavigation.Item>
</SideNavigation.List>
</SideNavigation.Root>
</Sidebar.Footer>
</>
);
} /* A row at every width, so the demo shows the sidebar even on small screens.
In an application, render Sidebar.Root as Layout.Aside instead. */
.frame {
display: flex;
border: var(--semantic-dimension-border-width-default) solid
var(--semantic-color-border-secondary);
border-radius: var(--semantic-dimension-radius-small);
}
.sidebar {
/* stylelint-disable-next-line design-tokens/no-hardcoded-token-values -- Demo frame size. */
block-size: 30rem;
}
.main {
flex: 1;
min-inline-size: 0;
padding: var(--semantic-dimension-space-padding-x-large);
} Usage
import {Layout, SideNavigation, Sidebar} from '@earendil-works/elanor-react';
<Sidebar.Root render={<Layout.Aside aria-label="Workspace sidebar" />}>
{({collapsed}) => (
<>
<Sidebar.Header>
<Sidebar.Trigger />
</Sidebar.Header>
<Sidebar.Content>
<SideNavigation.Root variant={collapsed ? 'compact' : 'default'} />
</Sidebar.Content>
<Sidebar.Footer />
</>
)}
</Sidebar.Root>;
- Root renders a
div; render it asLayout.Asidefor a single aside landmark. Its children can be a function that receives{collapsed}. - Header and Footer stay in place. Keep Trigger in the Header so it can’t scroll out of reach.
- Content scrolls. Its
ref, props, and scroll events go to the scrolling element. - Every region exposes
data-collapsedwhile collapsed, and itsrenderandclassNamecallbacks receive{collapsed}.
Compact content
Set variant={collapsed ? 'compact' : 'default'} on each Side navigation, and give every link an icon or compactContent in both states. Don’t swap in a different navigation tree when collapsing; the same links keep focus, tab order, and aria-current.
Collapsed links show their label in a tooltip on hover and focus. Branding, project switchers, and account controls need their own compact form: keep the same control mounted and change only its decorative content. Before removing a control that only exists when expanded, move focus to one that persists.
Controlled
Pass collapsed and onCollapsedChange to control the state, for example to persist it. details.cancel() in onCollapsedChange keeps the current state; a controlled owner that cancels must not update its own state either. Don’t switch between controlled and uncontrolled.
Code
import {
Button,
Checkbox,
SelectionControlAndLabel,
Sidebar,
Stack,
Text,
} from '@earendil-works/elanor-react';
import {useState} from 'react';
import {WorkspaceSidebar} from './workspace-sidebar.tsx';
import styles from './sidebar.module.css';
export default function SidebarControlled() {
const [collapsed, setCollapsed] = useState(false);
const [locked, setLocked] = useState(false);
const [message, setMessage] = useState('');
return (
<div className={styles.frame}>
<Sidebar.Root
collapsed={collapsed}
onCollapsedChange={(next, details) => {
if (locked) {
details.cancel();
setMessage('Change canceled.');
return;
}
setCollapsed(next);
setMessage(next ? 'Sidebar collapsed.' : 'Sidebar expanded.');
}}
className={styles.sidebar}
render={<aside aria-label="Workspace sidebar" />}
>
{({collapsed: current}) => <WorkspaceSidebar collapsed={current} />}
</Sidebar.Root>
<div className={styles.main}>
<Stack gap="medium">
<Button hierarchy="secondary" onClick={() => setCollapsed(!collapsed)}>
{collapsed ? 'Expand' : 'Collapse'} from outside
</Button>
<SelectionControlAndLabel
control={
<Checkbox.Root checked={locked} onCheckedChange={setLocked}>
<Checkbox.Indicator />
</Checkbox.Root>
}
>
Cancel changes from the sidebar button
</SelectionControlAndLabel>
<Text role="status">{message}</Text>
</Stack>
</div>
</div>
);
} import {
ChatIcon,
DataExplorationIcon,
GroupsIcon,
HomeIcon,
KeyIcon,
SettingsIcon,
} from '@earendil-works/elanor-icons-react';
import {Link, SideNavigation, Sidebar, Stack} from '@earendil-works/elanor-react';
import {useState, type ReactNode} from 'react';
const groups = [
{
label: 'Insights',
items: [
{label: 'Explore', icon: <DataExplorationIcon />},
{label: 'Sessions', icon: <ChatIcon />},
],
},
{
label: 'Manage',
items: [
{label: 'API keys', icon: <KeyIcon />},
{label: 'Members', icon: <GroupsIcon />},
{label: 'Settings', icon: <SettingsIcon />},
],
},
];
/** Sidebar regions shared by the demos. `collapsed` comes from Sidebar.Root. */
export function WorkspaceSidebar({collapsed}: {readonly collapsed: boolean}) {
const [current, setCurrent] = useState('Home');
const variant = collapsed ? 'compact' : 'default';
const link = (label: string, icon: ReactNode) => (
<SideNavigation.Item key={label}>
<SideNavigation.Link
href="#"
iconStart={icon}
active={label === current}
onClick={(event) => {
event.preventDefault();
setCurrent(label);
}}
>
{label}
</SideNavigation.Link>
</SideNavigation.Item>
);
return (
<>
<Sidebar.Header>
<Stack
direction={collapsed ? 'column' : 'row'}
gap={collapsed ? 'small' : 'auto'}
align="center"
style={{inlineSize: '100%'}}
>
<Link href="#" aria-label="Acme workspace">
{collapsed ? 'A' : 'Acme workspace'}
</Link>
<Sidebar.Trigger />
</Stack>
</Sidebar.Header>
<Sidebar.Content>
<SideNavigation.Root aria-label="Workspace" variant={variant}>
<SideNavigation.List>{link('Home', <HomeIcon />)}</SideNavigation.List>
{groups.map((group) => (
<SideNavigation.Group key={group.label}>
<SideNavigation.GroupLabel>{group.label}</SideNavigation.GroupLabel>
<SideNavigation.List>
{group.items.map((item) => link(item.label, item.icon))}
</SideNavigation.List>
</SideNavigation.Group>
))}
</SideNavigation.Root>
</Sidebar.Content>
<Sidebar.Footer>
<SideNavigation.Root aria-label="Account" variant={variant}>
<SideNavigation.List>
<SideNavigation.Item>
<SideNavigation.Link href="#" compactContent="AM">
Alex Morgan
</SideNavigation.Link>
</SideNavigation.Item>
</SideNavigation.List>
</SideNavigation.Root>
</Sidebar.Footer>
</>
);
} /* A row at every width, so the demo shows the sidebar even on small screens.
In an application, render Sidebar.Root as Layout.Aside instead. */
.frame {
display: flex;
border: var(--semantic-dimension-border-width-default) solid
var(--semantic-color-border-secondary);
border-radius: var(--semantic-dimension-radius-small);
}
.sidebar {
/* stylelint-disable-next-line design-tokens/no-hardcoded-token-values -- Demo frame size. */
block-size: 30rem;
}
.main {
flex: 1;
min-inline-size: 0;
padding: var(--semantic-dimension-space-padding-x-large);
} Trigger
The trigger is a Base UI Toggle named “Collapse sidebar”. aria-pressed="true" means collapsed. For other languages, override aria-label with one label that stays the same in both states; the tooltip only supplements it. It exposes data-pressed, not data-collapsed.
Size and scrolling
The sidebar is semantic.dimension.size.sidebar (234px) wide when expanded and semantic.dimension.size.xLarge (48px) when collapsed, including its 4px divider. Override the widths with CSS.
Root is at most 100dvh tall; reduce maxBlockSize below a sticky header. Set blockSize to fill the height even with little content.
The Content scrollbar appears while scrolling, not on hover. A fade at the bottom and a line at the top show there is more content; keyboard focus replaces the fade with a line so links stay visible.
Render the same collapsed state on the server and the client. For right-to-left layouts, set Base UI’s DirectionProvider to the document direction. On touch screens, don’t rely on tooltips to explain icons.
API
Sidebar.Root
| Prop | Type | Default | Description |
|---|---|---|---|
collapsed | boolean | - | Collapsed state, when controlled. |
defaultCollapsed | boolean | false | Initial collapsed state, when uncontrolled. |
onCollapsedChange | (collapsed: boolean, details: SidebarChangeEventDetails) => void | - | Called when the trigger toggles the sidebar. details.cancel() keeps the current state. |
children | ReactNode | ((state: SidebarState) => ReactNode) | - | Regions, or a function that receives {collapsed} and returns them. |
Renders <div>. Also accepts its HTML attributes, ref, and render.
Sidebar.Header
Renders <div>. Accepts its HTML attributes, ref, and render.
Sidebar.Trigger
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | 'Collapse sidebar' | Defines a string value that labels the current element. |
children | ReactNode | <DockToLeftIcon aria-hidden="true" /> |
Also accepts Base UI Toggle props.
Sidebar.Content
Renders <div>. Accepts its HTML attributes, ref, and render.
Sidebar.Footer
Renders <div>. Accepts its HTML attributes, ref, and render.