Skip to content
weftGitHub

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/warp

Usage

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.

tsx
import { state } from '@weft-js/core'import { SideNav } from '@weft-js/warp'export function Filter() {  const at = state('#invoices')  return <div onClick={follow(at)}><SideNav label="Guide" sections={GUIDE} current={at.value} filter="Filter pages" remember={false} /></div>}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}

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

Reach for something else

  • Nav, when there are a handful of links and no sections
  • Shell, when it is an app's own navigation, with an account menu
  • Accordion, when the sections hold content, not links

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
  • label names the navigation landmark and titles the panel.
  • Each section heading is a button with aria-expanded and aria-controls. A folded section is hidden, so its links leave the tab order.
  • The current page's link has aria-current="page".
  • The bar is a button named by label, with aria-controls naming the panel and aria-expanded saying 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

PropTypeDefaultDescription
labelstring—The name of the navigation landmark, and the panel's title on a phone.
sectionsSideNavSection[]—The sections, in order: title, items (href and label), and pinned to keep one always open with a plain heading.
currentstring—The current page's href. Its link is marked, its section opens, and the rail scrolls to it.
filterstring | boolean—Adds a filter box. A string is its placeholder.
rememberstring | 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>.
collapsiblebooleanfalseThe 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.
idstring—The panel's id. A button anywhere with aria-controls set to it also opens the panel on a phone.
leadSideNavLink[]—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

RelatedNavShellDrawer