SideNav
A docs sidebar of folding sections, with a collapse bar: docked on wide screens, opened over the page on phones.
SourcePackage @weft-js/warp
Preview
tsx
import { state } from '@weft-js/core'import { Card, Grid, Row, SideNav, Stack, Switch, Text } from '@weft-js/warp'export function Guide() { const at = state('#previews') // The bar at the panel's edge folds it, and under 900px opens it over the page: no button of its own needed. return <div onClick={follow(at)}><SideNav label="Guide" sections={GUIDE} current={at.value} remember="toolkit:sidenav-guide" /></div>}const DEPLOYS = [ { id: 'd-4821', project: 'storefront', branch: 'main', commit: '9f3ac21', message: 'Cache product images at the edge', author: 'Priya Raman', status: 'Live', minutes: 2, at: '2026-10-31T16:42' }, { id: 'd-4820', project: 'search-api', branch: 'ranking-v2', commit: '1be07d4', message: 'Rank exact matches first', author: 'Lena Fischer', status: 'Building', minutes: 6, at: '2026-10-31T16:10' }, { id: 'd-4819', project: 'mobile-bff', branch: 'main', commit: 'c44e190', message: 'Retry timed out upstream calls', author: 'Sam Okafor', status: 'Failed', minutes: 4, at: '2026-10-31T15:27' }, { id: 'd-4818', project: 'checkout', branch: 'main', commit: '70d2fa8', message: 'Add Apple Pay to the last step', author: 'Tomás Oliveira', status: 'Live', minutes: 3, at: '2026-10-31T14:03' }, { id: 'd-4817', project: 'edge-cache', branch: 'main', commit: 'e81b6c2', message: 'Lower the stale window to 30s', author: 'Jun Tanaka', status: 'Live', minutes: 1, at: '2026-10-31T11:55' }, { id: 'd-4816', project: 'billing', branch: 'main', commit: '2ad9e03', message: 'Prorate seats on plan change', author: 'Mara Lindqvist', status: 'Live', minutes: 3, at: '2026-10-29T17:20' },]const min = 60_000const panel = (view: string) => { if (view === 'overview') return ( <Grid min="xs" gap="related"> <Card eyebrow="Uptime" title="99.98%" /> <Card eyebrow="Latency" title="84 ms" /> <Card eyebrow="This month" title="$1,240" /> </Grid> ) if (view === 'deploys') return ( <Stack gap="related"> {DEPLOYS.slice(0, 3).map(d => ( <Row key={d.id} justify="between" gap="group"> <Text>{d.message}</Text> <Text role="code" subtle>{d.commit}</Text> </Row> ))} </Stack> ) if (view === 'logs') return <Text role="code" subtle>16:42 Deployed 9f3ac21 in 2m 04s</Text> return <Switch defaultChecked>Deploy every push to main</Switch>}const GUIDE = [ { title: 'Start here', pinned: true, items: [{ href: '#guide', label: 'Overview' }] }, { title: 'Projects', items: [{ href: '#create', label: 'Create a project' }, { href: '#invite', label: 'Invite your team' }, { href: '#archive', label: 'Archive a project' }] }, { title: 'Deploys', items: [{ href: '#first-deploy', label: 'Your first deploy' }, { href: '#previews', label: 'Preview deploys' }, { href: '#rollback', label: 'Roll back' }] }, { title: 'Billing', items: [{ href: '#plans', label: 'Plans' }, { href: '#invoices', label: 'Invoices' }] },]const follow = (at: { value: string }) => (e: MouseEvent) => { const a = (e.target as Element).closest?.('a') if (!a) return e.preventDefault() at.value = a.getAttribute('href') ?? at.value}Installation
npm install @weft-js/warpUsage
tsx
import { SideNav } from '@weft-js/warp'With a filter
filter adds a box that keeps the matching pages and opens every section they are in. Use it for long lists.
When to use
Use it for
- The pages of a docs site, a guide or a component library
- A list of pages long enough that a reader needs to see where they are in it
Accessibility
- Tab
- Moves between section headings, links and the bar; on a phone it stays inside the open panel
- EnterorSpace
- Opens or closes a section; on the bar, folds or unfolds the sidebar, or opens or closes the panel on a phone
- Escape
- Closes the panel on a phone
labelnames the navigation landmark and titles the panel.- Each section heading is a button with
aria-expandedandaria-controls. A folded section ishidden, so its links leave the tab order. - The current page's link has
aria-current="page". - The bar is a button named by
label, witharia-controlsnaming the panel andaria-expandedsaying whether the sidebar is unfolded, or on a phone whether the panel is open. - On a phone the open panel is a modal dialog labelled by its title: focus moves into it, stays inside, and goes back to the bar when it closes.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | — | The name of the navigation landmark, and the panel's title on a phone. |
sections | SideNavSection[] | — | The sections, in order: title, items (href and label), and pinned to keep one always open with a plain heading. |
current | string | — | The current page's href. Its link is marked, its section opens, and the rail scrolls to it. |
filter | string | boolean | — | Adds a filter box. A string is its placeholder. |
remember | string | false | — | The localStorage key for a reader's folds, and <key>:collapsed for whether they folded the sidebar. false keeps nothing. Default warp:sidenav:<label>. |
collapsible | boolean | false | The collapse bar at the panel's edge. Default true. false drops it, and the panel then opens on a phone only from a button whose aria-controls names its id, with a close button in its head. |
id | string | — | The panel's id. A button anywhere with aria-controls set to it also opens the panel on a phone. |
lead | SideNavLink[] | — | Links shown above the sections only on a phone, such as a site's top links. |
Parts
Every element carries a stable name, so you can style it from your own CSS: [data-scope="sidenav"] [data-part="backdrop"]
backdroppanelheadtitleclosescrollleaditemfiltersectionsgroup-labelfold-labelchevronindicatoremptybarbar-label