Skip to content
kerfjsGitHub

SideNav

A docs sidebar of folding sections, with a collapse bar: docked on wide screens, opened over the page on phones.

SourcePackage @kerf-js/veneer

Preview

tsx
import { state } from '@kerf-js/core'import { SideNav } from '@kerf-js/veneer'export function Guide() {  const at = state('#previews')  // The edge bar folds it. Under 900px it opens over  // the page, so no separate button is needed.  return (    <div onClick={follow(at)}>      <SideNav        label="Guide"        sections={GUIDE}        current={at.value}        remember="toolkit:sidenav-guide"      />    </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  }

Installation

npm install @kerf-js/veneer

Usage

tsx
import { SideNav } from '@kerf-js/veneer'

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 '@kerf-js/core'import { SideNav } from '@kerf-js/veneer'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
  • On a phone too: it stays as a bar down the left edge, beside the HamburgerMenu that holds the top links

Reach for something else

  • Nav, when there are a handful of links and no sections
  • HamburgerMenu, when the links are the site's top links on a phone
  • 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.

Theming

Restyle it app-wide from veneer() in your kerf config. Every key takes colour, spacing, size, radius, shadow, type and motion; its own keys are listed. A state nests inside: hover: { bg: 'ink' }. Text on a fill you set is contrast-checked at build. How theming works

KeyOwn keysStates
parts.close—hover, focus, press, active, open, disabled
parts.nav—none
parts.nav.item—hover, focus, press, active, open, disabled
parts.nav.underlinecolor, width, durationnone
ts
veneer({  colors: { brand: '#e8590c', ink: '#1b1b1f' },  parts: {    close: { size: 28 },    nav: { gap: 24, item: { hover: { text: 'brand' } }, underline: { color: 'brand', width: 3 } },  },})

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.
multiplebooleanfalseLets any number of sections stay open. By default one is open at a time: opening a section folds the others, and arriving on a page opens its own.
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 veneer: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.
overlay"cover" | "under"—On a phone, where the open panel sits. cover (default) lays it over everything, header included. under opens it below the header (--veneer-sidenav-top) with no title of its own: put a SideNavTitle around the brand's word in the header, and that word gives way to the panel's title while it is open.

Parts

Every element carries a stable name, so you can style it from your own CSS: [data-scope="sidenav"] [data-part="backdrop"]

backdroppanelheadtitleclosescrollfilteremptybarbar-label

RelatedNavShellDrawerHamburger menu