---
name: Sidebar
type: ATM
section: application
why: App-shell navigation rail with a collapsible state (State API) and a mobile overlay mode.
when: Persistent primary navigation beside the app content.
where: dist/components/sidebar/sidebar.css + dist/components/sidebar/sidebar.js
supportedStates: default, collapsed
---

# Sidebar

## Native basis

`<aside>` + `<nav>` + `<dialog>` for a full application sidebar with collapsible state, mobile sheet overlay, collapsible groups, submenus, and keyboard shortcut.

## Native Web APIs

- [`<aside>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/aside) - complementary content landmark
- [`<nav>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/nav) - navigation landmark for assistive technology
- [`<dialog>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog) - native modal for mobile sidebar overlay
- [`<details>/<summary>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/details) - collapsible groups and submenus without JS toggle logic
- [`::backdrop`](https://developer.mozilla.org/en-US/docs/Web/CSS/::backdrop) - dialog overlay styling
- [`@starting-style`](https://developer.mozilla.org/en-US/docs/Web/CSS/@starting-style) - slide-in animation for mobile dialog
- [`:focus-visible`](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) - keyboard-only focus ring on nav links
- [`overscroll-behavior`](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) - prevents scroll chaining in nav area
- [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) - suppresses all transitions

## Structure

### Full application layout
```html
<div class="sidebar-layout">
  <aside class="app-sidebar" id="main-sidebar" data-state="expanded">
    <div class="sidebar-header">
      <a class="sidebar-logo" href="/">
        <i data-lucide="command"></i>
        <span class="sidebar-logo-text">MyApp</span>
      </a>
    </div>
    <div class="sidebar-content">
      <details class="sidebar-group" open>
        <summary>
          <span>Platform</span>
          <i data-lucide="chevron-right"></i>
        </summary>
        <nav class="sidebar-nav">
          <a class="sidebar-link" href="#" aria-current="page">
            <i data-lucide="home"></i> <span>Dashboard</span>
          </a>
          <a class="sidebar-link" href="#">
            <i data-lucide="inbox"></i> <span>Inbox</span>
            <span class="sidebar-badge">12</span>
          </a>
          <details class="sidebar-submenu">
            <summary>
              <i data-lucide="settings"></i> <span>Settings</span>
              <i data-lucide="chevron-right"></i>
            </summary>
            <nav class="sidebar-nav">
              <a class="sidebar-link" href="#"><span>General</span></a>
              <a class="sidebar-link" href="#"><span>Team</span></a>
            </nav>
          </details>
        </nav>
      </details>
    </div>
    <div class="sidebar-footer">user@example.com</div>
  </aside>

  <dialog class="sidebar-mobile" id="mobile-sidebar">
    <button class="sidebar-mobile-close" aria-label="Close">
      <i data-lucide="x"></i>
    </button>
    <!-- same nav content as desktop -->
  </dialog>

  <main style="flex:1;min-width:0;">
    <button class="sidebar-trigger" data-sidebar-trigger="main-sidebar">
      <i data-lucide="panel-left"></i>
    </button>
  </main>
</div>
```

### Icons, emojis and activity dots

```html
<details class="sidebar-group" open>
  <summary>
    <span class="sidebar-icon" aria-hidden="true"><svg>…</svg></span>
    <span>Mail</span>
    <span class="sidebar-dot" role="img" aria-label="New activity"></span>
    <i data-lucide="chevron-right"></i>
  </summary>
  <nav class="sidebar-nav">
    <a class="sidebar-link" href="#">
      <span class="sidebar-icon" aria-hidden="true">📥</span>
      <span>Inbox</span>
      <span class="sidebar-dot" data-variant="info" data-animate="ping" role="img" aria-label="New mail"></span>
      <span class="sidebar-badge">12</span>
    </a>
  </nav>
</details>
```

- `.sidebar-icon` - a fixed 1rem box (0.875rem in a group heading) in front
  of a label: an `<svg>` or an emoji. Labels line up whatever the glyph, and
  the icon stays visible on the collapsed rail (a group heading keeps its
  icon there too).
- `.sidebar-dot` - an activity / "new" marker at the end of a link, submenu
  summary or group summary (a dot on a group tells a collapsed section has
  news). It takes the right edge (before the chevron); next to a
  `.sidebar-badge` they share it. A dot kept in the markup with `hidden` (news
  the reader has seen) leaves the right edge to the chevron or the badge. On
  the collapsed rail it moves onto the icon's top-right corner. Always name it
  (`role="img"` + `aria-label`).

## Activity dot (`.sidebar-dot`)

| Attribute | Values | Effect |
| --- | --- | --- |
| `data-variant` | *(none)* | `--sidebar-primary` |
|  | `success` / `warning` / `info` | Green / amber / blue status colors |
|  | `destructive` | `--destructive` |
| `data-animate` | `ping` | An expanding ring (off under `prefers-reduced-motion`) |


## Variants

| `data-state`   | Width    | Behavior                              |
| -------------- | -------- | ------------------------------------- |
| `expanded`     | 16rem    | Full sidebar with labels              |
| `collapsed`    | 3.5rem   | Icons only, text hidden               |

| `data-side`    | Position                              |
| -------------- | ------------------------------------- |
| *(none)*       | Left (default)                        |
| `right`        | Right side, border on left            |

## Density

Set `data-density` on the `.app-sidebar` root; link rows and the content frame scale. Independent of `data-state="collapsed"` (the rail keeps its own compact paddings).

| Value | Effect |
| --- | --- |
| `compact` | Link padding-block 0.375rem, frame 0.375rem |
| `comfortable` | 0.5rem - identical to the unsized default |
| `spacious` | 0.625rem |

## ARIA

| Attribute        | Element            | Purpose                            |
| ---------------- | ------------------ | ---------------------------------- |
| `<aside>`        | `.app-sidebar`     | Complementary landmark             |
| `<nav>`          | `.sidebar-nav`     | Navigation landmark                |
| `aria-current`   | `.sidebar-link`    | `"page"` for current page link     |
| `<dialog>`       | `.sidebar-mobile`  | Modal with focus trap + Escape     |

## Keyboard

| Key          | Action                                |
| ------------ | ------------------------------------- |
| `Cmd+B`      | Toggle sidebar collapse (macOS)       |
| `Ctrl+B`     | Toggle sidebar collapse (Windows)     |
| `Escape`     | Close mobile sidebar (native dialog)  |

## States

The api is bound **per `.app-sidebar`**. Declared states: `default`
(expanded - the authored width) · `collapsed` (icon rail, via the
documented `data-state="collapsed"` attribute). A sidebar written collapsed
starts in `collapsed`, and `setState('default')` expands it.

```js
document.querySelector('#my-sidebar').api.setState('collapsed');
document.querySelector('#my-sidebar').api.getState(); // { name: 'collapsed', config: {} }
```

Trigger clicks and Cmd/Ctrl+B keep `getState()` honest automatically.
The registry global is `df$.shadcn.sidebarApi` / `df$.shadcn.sidebarStates`.

## API

<!-- generated from the source by `bun run api-docs` - edit the JSDoc and the types in the .ts, not this section -->

### States

<code>type SidebarState = 'default' | 'collapsed'</code> - `setState(name, config)` takes the config of the state it names (`SidebarStateConfigs[name]`).

| State | Description |
|---|---|
| `default` | Expanded - the full width (a sidebar written collapsed starts in 'collapsed'). No config. |
| `collapsed` | Icons only (data-state="collapsed"). No config. |

### Every element

| Member | Description |
|---|---|
| <code>el.api.setState&lt;S extends SidebarState&gt;(name: S, config?: SidebarStateConfigs[S]): unknown</code> | Enter a state: the DOM work runs (also when it is the current state), the store records it. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>name</code></td><td><code>S</code></td><td>a declared state (an unknown name throws)</td></tr><tr><td><code>config?</code></td><td><code>SidebarStateConfigs[S]</code></td><td>that state's config (merged into the stored one when the component merges)</td></tr></table> <b>Returns</b> <code>unknown</code> - what the state's DOM work returned - a Promise for an async state (or await settled()) |
| <code>el.api.getState(): { name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code> | The state the element shows now - read back from the DOM, so it includes what the user changed. <b>Returns</b> <code>{ name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code> - the state's name, its config and the authored markup model render() starts from |
| <code>el.api.render(state?: { name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }): string</code> | The element's markup in a state - the authored markup with that state applied; a pure function of the state. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>state?</code></td><td><code>{ name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code></td><td>a state as getState() returns it (default: the current one)</td></tr></table> <b>Returns</b> <code>string</code> - the element's outer HTML in that state |
| <code>el.api.settled(): Promise&lt;void&gt;</code> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). <b>Returns</b> <code>Promise&lt;void&gt;</code> - resolves when nothing is pending |
| <code>el.store: Store&lt;{ name: SidebarState; config: SidebarStateConfigs[SidebarState] }&gt;</code> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |

### Registry

| Member | Description |
|---|---|
| <code>df$.shadcn.sidebarApi.setState&lt;S extends SidebarState&gt;(el: HTMLElement, name: S, config?: SidebarStateConfigs[S]): unknown</code> | Enter a state: the DOM work runs (also when it is the current state), the store records it. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>el</code></td><td><code>HTMLElement</code></td><td>the component's element</td></tr><tr><td><code>name</code></td><td><code>S</code></td><td>a declared state (an unknown name throws)</td></tr><tr><td><code>config?</code></td><td><code>SidebarStateConfigs[S]</code></td><td>that state's config (merged into the stored one when the component merges)</td></tr></table> <b>Returns</b> <code>unknown</code> - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled()) |
| <code>df$.shadcn.sidebarApi.getState(el: HTMLElement): { name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code> | The state the element shows now - read back from the DOM, so it includes what the user changed. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>el</code></td><td><code>HTMLElement</code></td><td>the component's element</td></tr></table> <b>Returns</b> <code>{ name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code> - the state's name, its config and the authored markup model render() starts from |
| <code>df$.shadcn.sidebarApi.render(state: { name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }): string</code> | The element's markup in a state - the authored markup with that state applied; a pure function of the state. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>state</code></td><td><code>{ name: SidebarState; config: SidebarStateConfigs[SidebarState]; model?: ElementModel }</code></td><td>a state as getState() returns it (with its model)</td></tr></table> <b>Returns</b> <code>string</code> - the element's outer HTML in that state |
| <code>df$.shadcn.sidebarApi.store(el: HTMLElement): Store&lt;{ name: SidebarState; config: SidebarStateConfigs[SidebarState] }&gt;</code> | The element's store (bindComponent made it). <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>el</code></td><td><code>HTMLElement</code></td><td>the component's element</td></tr></table> <b>Returns</b> <code>Store&lt;{ name: SidebarState; config: SidebarStateConfigs[SidebarState] }&gt;</code> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component |
| <code>df$.shadcn.sidebarApi.commit&lt;S extends SidebarState&gt;(el: HTMLElement, name: S, config?: SidebarStateConfigs[S]): void</code> | Record a state the element reached on its own (no DOM work) - for a component's own handlers. <table><tr><th>Argument</th><th>Type</th><th>Description</th></tr><tr><td><code>el</code></td><td><code>HTMLElement</code></td><td>the component's element</td></tr><tr><td><code>name</code></td><td><code>S</code></td><td>the state it is in</td></tr><tr><td><code>config?</code></td><td><code>SidebarStateConfigs[S]</code></td><td>its config</td></tr></table> |
| <code>df$.shadcn.sidebarStates: SidebarState[]</code> | The declared states, 'default' first: <code>default</code>, <code>collapsed</code>. |

## Notes

- **Mobile**: Desktop sidebar hidden below 768px. Use `<dialog class="sidebar-mobile">` for slide-in sheet. While it is modal, `html:has(.sidebar-mobile:modal)` sets `overflow: hidden` + `scrollbar-gutter: stable` - the page behind cannot scroll and keeps its position (no JS scroll-lock).
- **Auto-collapse**: When the sidebar's row (its container) drops below 24rem, the component docks itself to the icon rail; it restores above 28rem (hysteresis - a scrollbar appearing never flickers it). A deliberate choice always wins: trigger clicks, Cmd/Ctrl+B and `api.setState()` set `dataset.stateName`, which pins the sidebar against the heuristic until it is cleared.
- **Collapsible groups**: `<details class="sidebar-group">` - native toggle, no JS.
- **Submenus**: `<details class="sidebar-submenu">` for nested nav with left border.
- **Badges**: `<span class="sidebar-badge">` for notification counts.
- **Icons / emojis**: `<span class="sidebar-icon">` in front of any label (links, submenus, group headings).
- **Activity dots**: `<span class="sidebar-dot">` marks news / activity; stays visible on the collapsed rail.
- **Collapsed state**: Labels, titles, badges, footer, logo text hidden - icons remain. Submenu summaries (e.g. a Settings cog) center their icon in the rail too, and the nested nav stays hidden until expanded.
- **Sidebar tokens**: Uses `--sidebar-*` token group.
# Sidebar

## Native basis

`<aside>` + `<nav>` for application sidebar navigation with collapsible state.

## Native Web APIs

- [`<aside>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/aside) - complementary content landmark
- [`<nav>`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/nav) - navigation landmark for assistive technology
- [`:focus-visible`](https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible) - keyboard-only focus ring on nav links
- [`overscroll-behavior`](https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) - prevents scroll chaining in nav area
- [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) - suppresses width transition

## Structure

```html
<aside class="app-sidebar">
  <div class="sidebar-header">
    <span class="sidebar-logo">App</span>
  </div>
  <nav class="sidebar-nav">
    <span class="sidebar-section-title">Main</span>
    <a class="sidebar-link" data-active="true" href="#">
      <i data-lucide="home"></i> Dashboard
    </a>
    <a class="sidebar-link" href="#">
      <i data-lucide="settings"></i> Settings
    </a>
  </nav>
  <div class="sidebar-footer">
    <p>© 2026 App</p>
  </div>
</aside>
```

### Collapsed
```html
<aside class="app-sidebar" data-state="collapsed">
  <!-- content truncates at 3.5rem width -->
</aside>
```

## Variants

| `data-state`   | Width    | Behavior                    |
| -------------- | -------- | --------------------------- |
| *(default)*    | 16rem    | Full sidebar with labels    |
| `collapsed`    | 3.5rem   | Icons only, overflow hidden |

## ARIA

- `<aside>` provides complementary landmark automatically.
- `<nav>` provides navigation landmark automatically.
- Active link uses `data-active="true"` for styling; consider `aria-current="page"` for better screen reader support.

## Notes

- Sidebar uses design tokens from the `--sidebar-*` token group.
- The nav area has `overflow-y: auto` and `overscroll-behavior: contain` for scroll containment.
- Width transition is suppressed for users who prefer reduced motion.
- Pure CSS - no JavaScript required for rendering. Collapse toggle would need JS to toggle `data-state`.
- **Auto-collapse opt-out:** a row narrower than 24rem docks the rail to the
  icon rail automatically (448px restores it - hysteresis). Any deliberate
  state wins over that: once `data-state-name` is set - by a trigger click,
  `api.setState()`, or authored directly - auto-collapse stays out and the
  rail keeps its inline/width-class size at any width (labels clip via
  `overflow: hidden` instead of snapping to the dock). Useful for resizable
  panels: pair with the `resizer` component + `data-state-name="default"`.
