# Sidebar

The app's navigation column — a shell that owns its width and collapse, and the parts you stack inside it.

```slint
import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarMenu } from "@glint/components/sidebar.slint";
import { Separator } from "@glint/components/separator.slint";
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 760px;
    height: 420px;
    background: Tokens.color-background;

    property <string> route: "dashboard";

    bar := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Main";

        SidebarHeader {
            Button {
                variant: ButtonVariant.ghost;
                leading-icon: IconSet.Boxes;
                text: bar.collapsed ? "" : "Acme Inc";
                accessible-label: "Switch workspace";
            }
        }

        SidebarContent {
            SidebarMenu {
                heading: "Platform";
                collapsed: bar.collapsed;
                active: root.route;
                heading-action-icon: IconSet.Plus;
                heading-action-label: "Add a project";
                items: [
                    { label: "Dashboard", id: "dashboard", icon: IconSet.LayoutDashboard },
                    { label: "Inbox", id: "inbox", icon: IconSet.Inbox, badge: "12",
                      action-icon: IconSet.Ellipsis, action-label: "Inbox options",
                      children: [
                        { label: "Unread", id: "unread" },
                        { label: "Archived", id: "archived" },
                      ] },
                    { label: "Reports", id: "reports", icon: IconSet.ChartLine },
                ];
                navigate(id) => { root.route = id; }
            }

            Separator { }

            SidebarMenu {
                heading: "Projects";
                collapsed: bar.collapsed;
                active: root.route;
                items: [
                    { label: "Website", id: "website", icon: IconSet.Globe },
                    { label: "Mobile app", id: "mobile", icon: IconSet.Smartphone },
                ];
                navigate(id) => { root.route = id; }
            }
        }

        SidebarFooter {
            Button {
                variant: ButtonVariant.ghost;
                leading-icon: IconSet.CircleUser;
                text: bar.collapsed ? "" : "ada@example.com";
                accessible-label: "Account";
            }
        }
    }

    Rectangle {
        x: bar.width;
        y: 0;
        width: parent.width - bar.width;
        height: parent.height;

        VerticalLayout {
            padding: 24px;
            spacing: 12px;
            alignment: start;

            Button {
                variant: ButtonVariant.outline;
                leading-icon: IconSet.PanelLeft;
                text: bar.collapsed ? "Expand" : "Collapse";
                clicked => { bar.toggle-collapsed(); }
            }

            Text {
                text: "Showing: " + root.route;
                color: Tokens.color-muted-foreground;
                font-size: Tokens.typography-body-size;
            }
        }
    }
}
```

## Usage

```slint
import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarMenu } from "@glint/components/sidebar.slint";
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component AppWindow inherits Window {
    width: 720px;
    height: 400px;
    background: Tokens.color-background;

    property <string> route: "dashboard";

    bar := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Main";

        SidebarHeader {
            Button { variant: ButtonVariant.ghost; text: "Acme Inc"; }
        }

        SidebarContent {
            SidebarMenu {
                heading: "Platform";
                // The parts are told about the collapse: a slotted component
                // cannot read its host.
                collapsed: bar.collapsed;
                active: root.route;
                items: [
                    { label: "Dashboard", id: "dashboard", icon: IconSet.LayoutDashboard },
                    { label: "Reports", id: "reports", icon: IconSet.ChartLine },
                ];
                navigate(id) => { root.route = id; }
            }
        }

        SidebarFooter {
            Button { variant: ButtonVariant.ghost; text: "Account"; }
        }
    }

    // The page, beside the column. The sidebar owns its own width, so the
    // content area is measured from it.
    Rectangle {
        x: bar.width;
        width: parent.width - bar.width;
        height: parent.height;
    }
}
```

`Sidebar` owns the column itself — its chrome, its width, the collapse animation, `side`, `variant` and `collapsible`, and the edge that resizes and toggles it. It spends its one `@children` slot on the column’s contents, so a workspace switcher and a user menu are whatever they need to be rather than whatever a struct field could hold (ADR-0029).

Three parts go in that slot, and the menus go inside the middle one:

| Part             | Where it sits                                                                                                                   |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `SidebarHeader`  | In the column’s slot — the sticky top, a workspace switcher, a brand                                                            |
| `SidebarContent` | In the column’s slot — the scrolling middle, taking the room the two ends leave                                                 |
| `SidebarFooter`  | In the column’s slot — the sticky bottom, a user menu, a settings row                                                           |
| `SidebarMenu`    | Inside `SidebarContent`: one section of rows with an optional heading. Several of them are what makes a column read as sections |

**The parts that react to the collapse are told about it at the call site** (`collapsed: bar.collapsed`), because Slint lets neither a parent address its slotted children nor a slotted component read its host (ADR-0021). That one line is the whole wiring.

The column places itself: it publishes its own `width`, which is what the page beside it is measured from. Putting a `Sidebar` inside a layout hands the width to the layout instead, and the collapse animation goes with it.

## Examples

### What collapsing leaves behind

`collapsible` decides what is left when the column shuts. `SidebarCollapsible.icon` — the default — leaves the icon-only rail: labels, badges, chevrons and trailing actions leave the layout, and the icons stay. `offcanvas` takes the column to nothing but the edge. `none` is a column that does not collapse at all, and so offers no edge to collapse it with.

`collapsed` is the state, and it is two-way — an app that remembers whether the column was shut restores it by writing that property. `toggle-collapsed()` is the flip: it moves `collapsed` and fires `toggle()`, which is what the edge and a header button both call so the two cannot drift apart.

```slint
import { Sidebar, SidebarContent, SidebarMenu, SidebarCollapsible } from "@glint/components/sidebar.slint";
import { Button, ButtonVariant } from "@glint/components/button.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 780px;
    height: 320px;
    background: Tokens.color-background;

    rail := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Icon rail";
        collapsible: SidebarCollapsible.icon;
        expanded-width: 200px;
        SidebarContent {
            SidebarMenu {
                heading: "Icon";
                collapsed: rail.collapsed;
                active: "home";
                items: [
                    { label: "Home", id: "home", icon: IconSet.House },
                    { label: "Search", id: "search", icon: IconSet.Search },
                ];
            }
        }
    }

    off := Sidebar {
        x: rail.width + 8px;
        y: 0;
        height: parent.height;
        accessible-label: "Off-canvas";
        collapsible: SidebarCollapsible.offcanvas;
        expanded-width: 200px;
        SidebarContent {
            SidebarMenu {
                heading: "Offcanvas";
                collapsed: off.collapsed;
                active: "home";
                items: [
                    { label: "Home", id: "home", icon: IconSet.House },
                    { label: "Search", id: "search", icon: IconSet.Search },
                ];
            }
        }
    }

    fixed := Sidebar {
        x: rail.width + off.width + 16px;
        y: 0;
        height: parent.height;
        accessible-label: "Fixed";
        collapsible: SidebarCollapsible.none;
        expanded-width: 200px;
        SidebarContent {
            SidebarMenu {
                heading: "None";
                active: "home";
                items: [
                    { label: "Home", id: "home", icon: IconSet.House },
                    { label: "Search", id: "search", icon: IconSet.Search },
                ];
            }
        }
    }

    VerticalLayout {
        x: rail.width + off.width + fixed.width + 24px;
        y: 16px;
        width: 180px;
        spacing: 8px;
        alignment: start;

        Button {
            variant: ButtonVariant.outline;
            text: "Toggle the rail";
            clicked => { rail.toggle-collapsed(); }
        }
        Button {
            variant: ButtonVariant.outline;
            text: "Toggle off-canvas";
            clicked => { off.toggle-collapsed(); }
        }
    }
}
```

### Which side it stands on

`side` decides which of the column’s edges is the inner one — the edge the handle rides, and the direction the arrow keys widen it in. A right-hand column puts its handle on its left, and ← widens it.

```slint
import { Sidebar, SidebarContent, SidebarMenu, SidebarSide } from "@glint/components/sidebar.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 760px;
    height: 300px;
    background: Tokens.color-background;

    left := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Navigation";
        side: SidebarSide.left;
        expanded-width: 200px;
        SidebarContent {
            SidebarMenu {
                heading: "Left";
                collapsed: left.collapsed;
                active: "one";
                items: [
                    { label: "Overview", id: "one", icon: IconSet.House },
                    { label: "Activity", id: "two", icon: IconSet.Activity },
                ];
            }
        }
    }

    right := Sidebar {
        x: parent.width - self.width;
        y: 0;
        height: parent.height;
        accessible-label: "Inspector";
        side: SidebarSide.right;
        expanded-width: 220px;
        SidebarContent {
            SidebarMenu {
                heading: "Right";
                collapsed: right.collapsed;
                active: "props";
                items: [
                    { label: "Properties", id: "props", icon: IconSet.SlidersHorizontal },
                    { label: "History", id: "history", icon: IconSet.History },
                ];
            }
        }
    }

    Text {
        x: left.width + 24px;
        y: 24px;
        text: "Two columns, one either side. Drag or click either handle.";
        color: Tokens.color-muted-foreground;
        font-size: Tokens.typography-body-sm-size;
        wrap: word-wrap;
        width: parent.width - left.width - right.width - 48px;
    }
}
```

### How the column meets the page

`variant` is that meeting. `SidebarVariant.sidebar` is flush against the page, with a hairline down its inner edge. `floating` detaches it into a card with a gap around it and a shadow under it. `inset` draws no surface of its own — the column is a region of the page rather than a panel on it.

```slint
import { Sidebar, SidebarContent, SidebarMenu, SidebarVariant } from "@glint/components/sidebar.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 780px;
    height: 300px;
    background: Tokens.color-muted;

    flush := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Flush";
        variant: SidebarVariant.sidebar;
        expanded-width: 220px;
        SidebarContent {
            SidebarMenu {
                heading: "sidebar";
                collapsed: flush.collapsed;
                active: "a";
                items: [{ label: "Flush against the page", id: "a", icon: IconSet.PanelLeft }];
            }
        }
    }

    card := Sidebar {
        x: flush.width + 8px;
        y: 0;
        height: parent.height;
        accessible-label: "Floating";
        variant: SidebarVariant.floating;
        expanded-width: 220px;
        SidebarContent {
            SidebarMenu {
                heading: "floating";
                collapsed: card.collapsed;
                active: "a";
                items: [{ label: "A detached card", id: "a", icon: IconSet.Square }];
            }
        }
    }

    inset := Sidebar {
        x: flush.width + card.width + 16px;
        y: 0;
        height: parent.height;
        accessible-label: "Inset";
        variant: SidebarVariant.inset;
        expanded-width: 220px;
        SidebarContent {
            SidebarMenu {
                heading: "inset";
                collapsed: inset.collapsed;
                active: "a";
                items: [{ label: "No surface at all", id: "a", icon: IconSet.Frame }];
            }
        }
    }
}
```

### The edge

The edge is the column’s width made grabbable and its collapse made clickable. A press that travelled is a resize; a press that stayed put is the toggle — both live on the same handle, so the drag is what cancels the click.

`min-column-width` and `max-column-width` bound the drag, `expanded-width` is where it currently stands (two-way, so an app can restore a remembered width), and `resized` fires with the new width as it moves. From the keyboard, ← and → nudge it by `keyboard-step`, and Enter or Space collapses the column.

```slint
import { Sidebar, SidebarContent, SidebarMenu } from "@glint/components/sidebar.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 720px;
    height: 300px;
    background: Tokens.color-background;

    property <length> width-now: 240px;

    bar := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Resizable";
        edge-label: "Resize the navigation";
        min-column-width: 160px;
        max-column-width: 320px;
        keyboard-step: 24px;
        resized(w) => { root.width-now = w; }
        SidebarContent {
            SidebarMenu {
                heading: "Platform";
                collapsed: bar.collapsed;
                active: "dashboard";
                items: [
                    { label: "Dashboard", id: "dashboard", icon: IconSet.LayoutDashboard },
                    { label: "Settings", id: "settings", icon: IconSet.Settings },
                ];
            }
        }
    }

    VerticalLayout {
        x: bar.width + 24px;
        y: 24px;
        width: parent.width - bar.width - 48px;
        spacing: 8px;
        alignment: start;

        Text {
            text: "Column width: " + round(root.width-now / 1px) + "px";
            color: Tokens.color-foreground;
            font-size: Tokens.typography-body-size;
        }
        Text {
            text: "Drag the handle to resize, click it to collapse. Tab to it "
                + "and the arrows nudge it 24px at a time.";
            color: Tokens.color-muted-foreground;
            font-size: Tokens.typography-body-sm-size;
            wrap: word-wrap;
        }
    }
}
```

### What a menu row can be

A `SidebarItem` is a row: a `label`, the `id` you compare `active` against, a lucide `icon`, a `badge` at the trailing edge, and an optional trailing action button named by `action-label`. Give it `children` and it stops navigating — it becomes a parent that expands, the way a menu row with children opens instead of firing (ADR-0020). `expanded` is written back into the model, so the model is the state.

`heading-action-icon` puts one more button beside the section’s heading — “add a project”.

```slint
import { Sidebar, SidebarContent, SidebarMenu } from "@glint/components/sidebar.slint";
import { Tokens } from "@glint/theme/tokens.slint";
import { IconSet } from "@lucide";

export component Demo inherits Window {
    width: 720px;
    height: 380px;
    background: Tokens.color-background;

    property <string> log: "Pick a row, open a parent, or press a trailing action.";

    bar := Sidebar {
        x: 0;
        y: 0;
        height: parent.height;
        accessible-label: "Mail";
        expanded-width: 260px;
        SidebarContent {
            SidebarMenu {
                heading: "Mailboxes";
                heading-action-icon: IconSet.Plus;
                heading-action-label: "New mailbox";
                collapsed: bar.collapsed;
                active: "inbox";
                items: [
                    { label: "Inbox", id: "inbox", icon: IconSet.Inbox, badge: "24",
                      action-icon: IconSet.Ellipsis, action-label: "Inbox options" },
                    { label: "Projects", id: "projects", icon: IconSet.Folder,
                      expanded: true,
                      children: [
                        { label: "Website", id: "p-web", icon: IconSet.Globe },
                        { label: "Mobile", id: "p-mob", icon: IconSet.Smartphone,
                          badge: "3" },
                      ] },
                    { label: "Sent", id: "sent", icon: IconSet.Send },
                ];
                navigate(id) => { root.log = "Navigated to " + id + "."; }
                action(id) => { root.log = "Action on " + id + "."; }
                toggled(id, open) => {
                    root.log = id + " is now " + (open ? "open" : "shut") + ".";
                }
                heading-action() => { root.log = "New mailbox."; }
            }
        }
    }

    Text {
        x: bar.width + 24px;
        y: 24px;
        width: parent.width - bar.width - 48px;
        text: root.log;
        color: Tokens.color-muted-foreground;
        font-size: Tokens.typography-body-sm-size;
        wrap: word-wrap;
    }
}
```

## API Reference

### Properties

| Property           | Type                    | Default                   | Description                                                                |
| ------------------ | ----------------------- | ------------------------- | -------------------------------------------------------------------------- |
| `collapsed`        | `in-out bool`           | `false`                   | Two-way; when true the column shrinks to what `collapsible` leaves.        |
| `collapsible`      | `in SidebarCollapsible` | `SidebarCollapsible.icon` | What collapsing leaves behind.                                             |
| `side`             | `in SidebarSide`        | `SidebarSide.left`        | Which side of the app the column stands on.                                |
| `variant`          | `in SidebarVariant`     | `SidebarVariant.sidebar`  | How the column meets the page.                                             |
| `collapsed-width`  | `in length`             | `56px`                    | Width of the icon-only rail.                                               |
| `expanded-width`   | `in-out length`         | `240px`                   | Two-way; width while expanded. The edge drags it, within the bounds below. |
| `min-column-width` | `in length`             | `180px`                   | How narrow and how wide a drag may take the column.                        |
| `max-column-width` | `in length`             | `400px`                   |                                                                            |
| `keyboard-step`    | `in length`             | `16px`                    | How far one arrow key, or one accessible increment, moves the edge.        |
| `edge-label`       | `in string`             | `@tr("Resize sidebar")`   | Name of the edge handle; override to translate.                            |

### Callbacks

| Callback          | Description                                                  |
| ----------------- | ------------------------------------------------------------ |
| `toggle()`        | Fired after the column collapses or expands.                 |
| `resized(length)` | Fired with the column's new width while the edge is dragged. |

### Functions

| Function             | Description |
| -------------------- | ----------- |
| `toggle-collapsed()` |             |

### Enums

| Enum                 | Values                         |
| -------------------- | ------------------------------ |
| `SidebarCollapsible` | `offcanvas`, `icon`, `none`    |
| `SidebarSide`        | `left`, `right`                |
| `SidebarVariant`     | `sidebar`, `floating`, `inset` |

### SidebarHeader

The sticky top of the column. It keeps its own height while the scrolling middle takes what is left.

`SidebarHeader` publishes no properties, callbacks or functions of its own. What a call site can set on it is the Slint `Rectangle` it inherits.

### SidebarContent

The scrolling middle. It rides the library’s one scrolling surface (ADR-0019), so a menu longer than the column scrolls instead of overflowing it.

`content-takes-focus` defaults to `true` here, where [ScrollArea](/docs/components/scroll-area)’s own default is `false`: a column of `SidebarMenu` rows answers the keyboard itself, so the region needs no stop of its own. The body is `@children` though, so it stays the consumer’s fact — a column filled with inert rows and left at `true` is a scrolling region with no tab stop at all, which is content a keyboard user cannot read.

### Properties

| Property              | Type      | Default | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| --------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `content-takes-focus` | `in bool` | `true`  | Whether what is slotted in here answers the keyboard itself. A `SidebarMenu` does — every row of one is a focusable button — which is why this defaults to true where ADR-0032's own default is false — but the body is `@children`, so it is the consumer's fact and not this component's to settle. A column filled with inert rows and left at true is a scrolling region with no tab stop at all, which is content a keyboard user cannot read; `DialogPanel` and `AttachmentGroup` publish the property for the same reason. |

### SidebarFooter

The sticky bottom of the column.

`SidebarFooter` publishes no properties, callbacks or functions of its own. What a call site can set on it is the Slint `Rectangle` it inherits.

### SidebarMenu

One section of the column: an optional heading, an optional action beside it, and the rows. A `Separator` between two sections is yours to place, since the content region is a slot.

### Properties

| Property               | Type                   | Default    | Description                                                                                                                     |
| ---------------------- | ---------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `items`                | `in-out [SidebarItem]` | no default | Rows top-to-bottom. `in-out` because expanding a parent row writes its new `expanded` back here.                                |
| `active`               | `in-out string`        | no default | Two-way; id of the active row. Compare against `items[i].id`.                                                                   |
| `collapsed`            | `in bool`              | `false`    | True while the column is collapsed to its rail: labels, badges and actions leave the layout and only the icons remain.          |
| `heading`              | `in string`            | no default | Names the section, above its rows. Empty draws no heading.                                                                      |
| `heading-action-icon`  | `in LucideIcon`        | no default | An action beside the heading — "add a project". An empty Icon draws none; the label names it, since the button carries no text. |
| `heading-action-label` | `in string`            | no default |                                                                                                                                 |

### Callbacks

| Callback                 | Description                                              |
| ------------------------ | -------------------------------------------------------- |
| `navigate(string)`       | Fired with the chosen row's id; `active` updates first.  |
| `action(string)`         | Fired with the row a trailing action sits on.            |
| `toggled(string , bool)` | Fired with a parent row's id and whether it is now open. |
| `heading-action()`       | Fired by the action beside the heading.                  |

`SidebarItem` and `SidebarSubItem` are data types rather than components:

| Type             | Fields                                                                                |
| ---------------- | ------------------------------------------------------------------------------------- |
| `SidebarItem`    | `label`, `id`, `icon`, `badge`, `action-icon`, `action-label`, `children`, `expanded` |
| `SidebarSubItem` | `label`, `id`, `icon`, `badge`                                                        |

## Accessibility

- **The shell is a navigation landmark.** `Sidebar` carries `accessible-role: navigation` and no name of its own, so a call site’s `accessible-label` lands on it. An app with two columns needs them told apart.
- **A menu is a list named by its heading.** `SidebarMenu` publishes `accessible-role: list` with its heading as the label and its row count as the item count. A call site that wants other wording overrides `accessible-label`.
- **Every row is a named button** carrying its index, whether it is selected, and — for a parent — whether it is expanded. The name lives on the row rather than on its text, so it survives the collapse that drops the label out of the layout: the icon-only rail still announces “Dashboard”.
- **A badge is content, not a node.** The count rides as the row’s `accessible-description`, so the row speaks as a single control rather than as a control followed by a stray number.
- **A trailing action is its own control.** It sits above the row’s touch area and takes the press before it, carries `action-label` as its name, and has its own tab stop and focus ring — a control inside a row is a control, not a part of the row (ADR-0028).
- **Sub-rows are a list of their own.** A parent’s children are announced with their own count and their own indices — an unnamed list, so a sub-row is “2 of 3” rather than a second row 0 inside the section’s count (ADR-0012). While the parent is shut they are gone from the tree, not merely hidden — and the rail shows none of them, because it has no room for them.
- **The edge speaks as a slider.** Slint 1.17 has no separator role (ADR-0015), and a slider is the role that carries a value, its bounds and the actions that move it: the edge publishes `expanded-width` as its value — the width the column returns to, not the width it currently draws — along with `min-column-width`, `max-column-width` and `keyboard-step`. It answers increment and decrement, accepts a value directly, and takes the collapse as its default action.
