# 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](/components/drawer.md) instead; see the [responsive navigation recipe](/components/top-navigation.md#responsive-navigation).

```tsx
// 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>
  );
}
```

```tsx
// 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>
    </>
  );
}
```

```css
/* 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

```tsx
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](/components/side-navigation.md), 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.

```tsx
// 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>
  );
}
```

```tsx
// 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>
    </>
  );
}
```

```css
/* 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`

| 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`](https://base-ui.com/react/components/toggle.md) props.

### `Sidebar.Content`

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

### `Sidebar.Footer`

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