Sidebar
The app's navigation column — a shell that owns its width and collapse, and the parts you stack inside it.
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; 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
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.
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.
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.
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.
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”.
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’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.
Sidebarcarriesaccessible-role: navigationand no name of its own, so a call site’saccessible-labellands on it. An app with two columns needs them told apart. - A menu is a list named by its heading.
SidebarMenupublishesaccessible-role: listwith its heading as the label and its row count as the item count. A call site that wants other wording overridesaccessible-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-labelas 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-widthas its value — the width the column returns to, not the width it currently draws — along withmin-column-width,max-column-widthandkeyboard-step. It answers increment and decrement, accepts a value directly, and takes the collapse as its default action.