VIII Wayfinding

Star on GitHubFeedback
VIII WayfindingNative HTML and a hookot-sidebar

Sidebar

A column of words beside the work. Folded, it becomes a ruler: a numeral for each group, a tick for each link, and the name comes out into the margin when you point at it.

Report an issue with Sidebar

Example

Projects

Seven projects, three of them live. Choose a page on the left, or fold the column to a ruler and point at a tick.

Rest
Pointed at
Current
Chapter, closed and opened
Numerals

Install

Run it in a project that has the shadcn CLI set up. It brings the 0nlyType base, button, scrollbar, and sheet along.

npx shadcn@latest add https://thedirectors.agency/ui/r/sidebar.json

Usage

sidebar-example.tsx
"use client"import * as React from "react"import { Button } from "@/components/ui/button"import { Sidebar, SidebarGroup, SidebarHead, SidebarLink, type SidebarProps } from "@/components/ui/sidebar"// Folded, pointing at a tick names it and shows its line.const pages = {Work: { Projects: "Seven of them, three live.", Clients: "Who we work for, and since when.", Journal: "What the studio writes down." },"II Studio": { People: "Nine, and a dog.", Settings: "Hours, billing and who can sign in." },}type Variant = NonNullable<SidebarProps["variant"]>const variants: Variant[] = ["words", "chapter", "numerals"]export default function Example() {const [current, setCurrent] = React.useState("Projects")const [folded, setFolded] = React.useState(false)const [variant, setVariant] = React.useState<Variant>("words")return (<div className="grid gap-10 min-[860px]:grid-cols-[auto_minmax(0,1fr)]"><Sidebar id="studio-app" label="Studio app" sheetLabel="Studio" folded={folded} variant={variant}><SidebarHead>Halden Studio</SidebarHead>{Object.entries(pages).map(([group, names]) => (<SidebarGroup key={group} label={group}>{Object.entries(names).map(([name, line]) => (<SidebarLinkkey={name}href={`#${name.toLowerCase()}`}current={name === current}preview={line}onClick={(e) => {e.preventDefault()setCurrent(name)}}>{name}</SidebarLink>))}</SidebarGroup>))}</Sidebar><div className="grid content-start justify-items-start gap-4"><div role="group" aria-label="Sidebar variant" className="flex flex-wrap gap-x-6 gap-y-2">{variants.map((v) => (<Button key={v} variant="quiet" aria-pressed={v === variant} disabled={folded} onClick={() => setVariant(v)}>{v[0].toUpperCase() + v.slice(1)}</Button>))}</div><Button variant="quiet" aria-expanded={!folded} aria-controls="studio-app" onClick={() => setFolded((f) => !f)}>{folded ? "Unfold the sidebar" : "Fold the sidebar"}</Button><p className="ot-mf">{current}</p><p>Seven projects, three of them live. Choose a page on the left, or fold the column to a ruler and point at a tick.</p></div></div>)}

Contract

  • Underneath: native, plus a hook; a sheet on narrow screens.
  • Anatomy: <nav class="ot-sidebar ot-scroll"> holding .ot-sidebar-list (with .ot-sidebar-head, groups, and a .ot-scrollbar as the nav's last child). A group is .ot-sidebar-label (.ot-sidebar-i the numeral or initial, .ot-sidebar-rest the remainder) over .ot-sidebar-links; a link is .ot-sidebar-link holding .ot-sidebar-text. aria-current="page" on the current one. data-folded folds the list; data-compact sets it a size down with the links closed up, for a long index. While folded, one .ot-sidebar-callout (aria-hidden, position: fixed, pointer-events: none) is placed beside whatever is pointed at or focused.
  • Unfolded: words, with a dot before the current page and a ring before the one pointed at. Folded: a ruler about 3rem wide (--ot-sidebar-rail) on the column's own hairline. Each group keeps its roman numeral ("VI Controls" keeps VI) or else its initial, in pencil (ink for the group you are in); each link is a short tick off the hairline that stretches when pointed at; the current page's tick is the longest, with the accent dot at its far end. Both modes say the same thing: where you are, and what each thing is called.
  • Pointing at a tick, or focusing it, names it in the margin: a leader line is drawn from the tick to its full name, the group's name beneath, and any preview (a sentence) after a beat. Pointing at a numeral names the whole group. It stays inside the window, never takes the pointer, and Escape puts it away. The words stay in the page as the link's accessible name, folded or not; the callout is a pointer aid and is hidden from assistive technology.
  • The words fold away in turn (--ot-arpeggio) while the ticks grow; with reduced motion it is instant.
  • Scrolling: the nav is the scroller and takes the scrollbar's rail, which rides the column's own hairline (--line: 0). Folded ticks are 1.5rem tall so each is a target.
  • variant on the list (data-variant): words (the default), chapter or numerals. Both apply unfolded, in the column and the sheet; folded, every variant is the same ruler.
  • chapter: a book's running contents, open only at the chapter you are reading. The group holding the current page stands open, its label in ink; every other group keeps only its label. A still pointer (a 240ms wait) opens one under its name, and focus opens it at once, so Tab still reaches every link; leaving closes it. The links stay in the page for assistive technology.
  • numerals: "14 Where my feet stand, 08 there i take a photo". Each group's place (01, 02, a CSS counter, drawn and not read) stands large in the margin (f 300, tabular), in pencil, ink for the group you are in or point into. The label, a leading roman numeral and all, and the links sit small beside it (pp over p), from the numeral's cap height; the numerals share one column (subgrid), so they align. A roman numeral isn't set large: at display size a grotesque's I is a bar.
  • Keyboard: links (Tab moves tick to tick and shows each name), plus the fold button you give it (aria-expanded, aria-controls naming the nav). Below 860px the same words open in a sheet and never fold.

Props

Props of Sidebar
PropTypeDefaultWhat it does
Sidebar labelstringNames the nav landmark, such as Studio app or Docs.
Sidebar sheetLabelstring"Index"Below 860px only a quiet trigger with this word shows, and it opens the same words in a sheet. Choosing a link puts the sheet away.
Sidebar foldedbooleanfalseThe column folds to a ruler about 3rem wide: each group keeps its numeral (or initial), each link a tick, the page you are on the longest tick with the dot. Pointing at or focusing a tick draws a leader line to its full name in the margin. The words fold away in turn. Wide only: the sheet never folds. Pair it with your own button and aria-expanded.
Sidebar variant"words" | "chapter" | "numerals""words"words: every group open. chapter: only the group holding the current page stands open; the rest keep their names and open for a still pointer, or at once for focus, so Tab still reaches every link. numerals: each group's place (01, 02) set large in the margin, in pencil, ink for the group you are in; its label and links sit small beside it. Folded, every variant is the same ruler.
Sidebar compactbooleanfalseA size down, with the links closed up: for a long index, such as these docs.
SidebarHeadp propsThe column's own name, at the top. Folded, it keeps its initial too.
SidebarGroup labelstringA small pencil label over a list of links, such as a movement or a section. Folded, a leading roman numeral stays ("VI Controls" becomes VI); otherwise the initial. Pointing at it names the whole group.
SidebarLink currentbooleanfalseMarks the page you are on: aria-current="page", and the one accent dot.
SidebarLink previewReactNodeWhat appears under the name in the margin while the column is folded and the link is pointed at or focused: a sentence about where it goes. The group's name sits between them. It is a pointer aid; the link's own words are always its accessible name.
SidebarLink asChildbooleanfalseRender the child, such as next/link, with the sidebar's look. Plain-text children still fold.
ScrollingnoteThe nav is the scroller: give it a height (or max-height) and it scrolls on the scrollbar's rail, which runs down the column's own rule.
Server componentsnoteSidebar and its parts can be used from a server component; the state they need lives inside them.

Craft

Where its move comes from

Where its move comes from
ComponentReferenceMove
Sidebar"It has to be design."Folded, each word keeps its initial
Chapter sidebarA book's running contentsOnly the chapter you are in stands open
Numerals sidebar"the silence that heals" (14 / 08)Each group's number large, its links small beside it

How it moves

How it moves
ComponentArticulationWhat moves
SidebarFoldThe words fold to their initials in turn
Chapter sidebarOpenA still pointer opens a closed group under its name; leaving closes it
Numerals sidebarInkPointing into a group inks its number