Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Docs sidebar

A docs sidebar in accordion sections whose pages hang from a tree: the path to the page under the pointer lights up in its section’s colour.

docs-sidebar/docs-sidebar.tsx
Live · interactive

The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.

When to use

  • Documentation or help centres split into a few big topics, each with a handful of pages.
  • When reading one topic at a time is the norm: opening a section folds the others away, so the list stays short.
  • Topics worth telling apart at a glance: each section has its own accent colour for its mark, branch and icon.
  • Sidebars of 2 to 6 sections with up to about 8 pages each, where every row fits on one line.

When not to use

  • Deep hierarchies with pages inside pages. The tree has one level; use a nested tree view.
  • When readers compare pages across sections side by side. Keep every section open in a plain list.
  • Dozens of sections or long page titles: rows are a fixed height and never wrap. Add a search field or a table of contents.
  • The main app navigation, where every place should be one press away. Use a flat sidebar.

You can add this component with the shadcn CLI or by hand:

Into components/docs-sidebar of any React project, with no setup:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/docs-sidebar.json

Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

Usage

docs-nav.tsx
import { DocsSidebar } from './docs-sidebar';import { DOCS } from './sections';export function DocsNav({ go }: { go: (page: string) => void }) {  return (    <DocsSidebar      sections={DOCS}      label="Documentation"      defaultOpen="reporting"      defaultPage="overview"      onSelect={go}      autoplay={false}    />  );}

Each state below is a live instance, locked so you can study it.

The pieces it is built from, each with its measurements read live.

A navigation for an email platform’s docs, one section open at a time. Opening another folds the open one away while the new pages unroll beneath their title, and the mark on the rail slides across, changing colour. The page under the pointer turns bold in full-strength ink, and its icon and the branch that leads to it take the section’s colour.

  • Every row has a fixed height, so the layout of each state is known in advance: the sections sit at offsets worked out from which one is open, and a switch moves them there with a transform.
  • The opening section’s pages are revealed from the top with a clip-path whose bottom edge keeps pace with the sections sliding below; the closing section’s pages fold up the same way. One duration and one easing keep the accordion moving as one piece.
  • The tree is an SVG per section, one branch per page from the top of the trunk to the page’s rounded elbow. Each branch is drawn twice, in grey and in the section’s colour, and only the lit page’s coloured branch shows, so the whole path down to it lights up.
  • The light is sticky: the last page the pointer touched stays lit until another one is, and leaving the sidebar hands it back to the current page. Focus lights pages the same way.
  • The mark is one element that slides to the open title and fades to its colour; the rail scales to the length of the open state when sections hold different numbers of pages.
  • Every colour is a light-dark() token, so it follows the page’s color-scheme, or the colorScheme prop: a white sidebar by day, a black one at night. On white each section’s colour is deepened a little with color-mix(), so the mark, the lit branch and the lit icon still hold; an accent passed in is deepened the same way.
PropTypeDefaultDescription
sectionsSection[]DOCSThe sections, in order. A section has an id, a title, an accent colour and its pages; a page has an id, a label and an icon (a component drawing a 24 × 24 line icon in the current colour).
labelstring'Documentation'The navigation’s accessible name.
defaultOpenstring—The id of the section open at first; the first section when left out.
defaultPagestring—The id of the current page at first, marked with aria-current; the open section’s first page when left out.
defaultHoverstring—The id of a page in the open section drawn lit as if pointed at, until the pointer leaves. Turns the demo off.
onSelect(id: string) => void—Called with a page’s id when it is chosen.
autoplaybooleantrueWhile on screen and untouched, a demo pointer runs down the open section’s pages and opens the next section, in turn.
colorScheme'light' | 'dark' | 'system'—Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one.
classNamestring—Extra class for the root, a stage of its own.

The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.

docs-sidebar.tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import {  useEffect,  useId,  useLayoutEffect,  useRef,  useState,  type CSSProperties,  type KeyboardEvent,  type PointerEvent,} from 'react';import { GhostPointer, runDemo } from './ghost-pointer';import { DOCS, type Section } from './sections';import { useAutoplay } from './use-autoplay';import styles from './docs-sidebar.module.css';/* * Docs sidebar: a docs navigation in sections, one open at a time, its pages hanging from a tree. * * - Every row has a fixed height, so the layout of each state is known in advance: the sections *   sit at offsets worked out from which one is open, and a switch moves them there with a
docs-sidebar.module.css
/* Docs sidebar (docs-sidebar.tsx): a white stage, or at night a black one, with the navigation in   its middle. The component places each section with a transform (its offsets come from which   section is open) and sets --accent, the colour of each section, on the sections and on the mark.   The rows' sizes here (a title 46px tall, a page 36px, 4px above the first page) match the   constants it uses.   Colours are the tokens below, light-dark(light, dark): the component takes the color-scheme it   inherits from the page (light unless the page says otherwise), or the one its colorScheme prop   sets (data-color-scheme). */.root {  /* The stage, and its edge. */  --surface: #fff;  --edge: rgb(0 0 0 / 0.08);  /* The open title and the lit page; the other pages, and their icons. */  --ink: #171717;  --ink-muted: #666;  --page-icon: #8a8a8a;  /* The closed titles, and one under the pointer. */  --dim: #737373;  --dim-hover: #525252;  /* The rail, and the tree's branches. */  --rail: #e8e8e8;  --line: #e3e3e3;
sections.ts
import type { ComponentType } from 'react';import {  AlertIcon,  BarsIcon,  BlocksIcon,  BoltIcon,  BracesIcon,  EnvelopeIcon,  EyeIcon,  HistoryIcon,  PenIcon,} from './icons';/* * The sidebar's content: the sections of an email platform's docs, each with its accent colour * and its pages. */export type Page = {  /** Unique across the sidebar. */  id: string;  label: string;  /** A 24 × 24 line icon, stroked in the current colour. */  icon: ComponentType;
icons.tsx
import type { ReactNode } from 'react';/* * The pages' line icons: 24 × 24 drawings stroked in the current colour, so a page's icon takes * its section's accent when it lights up. The sidebar sets their size and stroke. */function Icon({ children }: { children: ReactNode }) {  return (    <svg viewBox="0 0 24 24" aria-hidden="true">      {children}    </svg>  );}export function EnvelopeIcon() {  return (    <Icon>      <rect x="2" y="4" width="20" height="16" rx="3" />      <path d="m3 7 9 6.25L21 7" />    </Icon>  );}
ghost-pointer.tsx
'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's ghost pointer, and the timeline that moves it. A demo is a script of awaited steps: * wait, glide the pointer onto a control, press it and call the component's own handler, as a * click would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in * progress at once: its promise rejects, which ends the script right there, and the pointer * fades out. */export type Demo = {  /** Resolves after `ms`. */  wait: (ms: number) => Promise<void>;  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */  moveTo: (target: Element | null | undefined, at?: { x: number; y: number }) => Promise<void>;  /** Presses and lets go; `action` runs as the pointer goes down. */  press: (action: () => void) => Promise<void>;};/** What a stopped demo's pending step rejects with. */class Stopped extends Error {}
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • It is a navigation landmark. Section titles are disclosure buttons with aria-expanded and aria-controls; the open one is also aria-disabled, as one section always stays open, and closed sections are inert.
  • Pages are buttons, and the current one carries aria-current="page". Choosing a page or opening a section (“Email Reporting, 3 pages”) is announced in a polite live region.
  • Tab moves through the titles and the open section’s pages, and the up and down arrows, Home and End move between them too; focus lights a page as the pointer does, with a visible outline.
  • The rail, the mark, the tree and the icons are decorative and hidden from screen readers. Under reduced motion sections switch at once and the demo does not play.