Skip to content

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.

Collapse the sidebar with the button next to the workspace name.
Code
demo.tsx
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>
  );
}
workspace-sidebar.tsx
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>
    </>
  );
}
sidebar.module.css
/* 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 as Layout.Aside for 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-collapsed while collapsed, and its render and className callbacks 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
controlled.tsx
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>
  );
}
workspace-sidebar.tsx
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>
    </>
  );
}
sidebar.module.css
/* 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

PropTypeDefaultDescription
collapsedboolean-Collapsed state, when controlled.
defaultCollapsedbooleanfalseInitial collapsed state, when uncontrolled.
onCollapsedChange(collapsed: boolean, details: SidebarChangeEventDetails) => void-Called when the trigger toggles the sidebar. details.cancel() keeps the current state.
childrenReactNode | ((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

PropTypeDefaultDescription
aria-labelstring'Collapse sidebar'Defines a string value that labels the current element.
childrenReactNode<DockToLeftIcon aria-hidden="true" />

Also accepts Base UI Toggle props.

Sidebar.Content

Renders <div>. Accepts its HTML attributes, ref, and render.

Renders <div>. Accepts its HTML attributes, ref, and render.