# 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.

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.

A React component by Bruno Delorence: https://brunodelorence.com/components/docs-sidebar. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: Navigation.

## Anatomy

1. **Rail**: A hairline along the left edge, as long as the open state needs; it scales to the new length when a section opens.
2. **Mark**: Slides along the rail to the open section’s title and takes its colour.
3. **Open title**: A disclosure button with `aria-expanded`. Open, it is in full ink and pressing it again does nothing.
4. **Lit branch**: The lit page’s branch, from the top of the trunk to its rounded elbow, in the section’s colour. The other branches stay grey.
5. **Lit page**: The page under the pointer, or else the current one: bold, in full ink, its icon in the section’s colour.
6. **Page icon**: A 16 px line icon, stroked in the current colour, so it greys out and lights up with its row.
7. **Page**: A 36 px row with its icon and its name. Choosing it marks it `aria-current="page"` and calls `onSelect`.
8. **Closed title**: Dimmed until pointed at. Pressing it opens its section and folds the open one away.

## 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.

## Installation

Install it with the shadcn CLI, into components/docs-sidebar of any React project:

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

Or copy all the source files below into one folder of your project. Either way it needs React 19 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: 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

```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}
    />
  );
}
```

## States

- **Default**: The first section open and its first page current, so the light starts on it.
- **Another section open**: The sections below the open one slide down to make room; the mark moves and turns blue.
- **Last section open**: The sections above stay put; its pages hang from the bottom of the list.
- **Current page**: Set with `defaultPage`: the light rests on the page being read.
- **Pointed at**: The page under the pointer takes the light from the current one, until the pointer leaves the sidebar.
- **Light**: On white, the sections’ colours are deepened a little so they hold.
- **Dark**: On black, the colours are worn as given. `colorScheme` sets it, or the page’s scheme does.

## How it works

- 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.

## API reference

- **sections** (`Section[]`, default `DOCS`): The 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).
- **label** (`string`, default `'Documentation'`): The navigation’s accessible name.
- **defaultOpen** (`string`): The id of the section open at first; the first section when left out.
- **defaultPage** (`string`): The id of the current page at first, marked with aria-current; the open section’s first page when left out.
- **defaultHover** (`string`): 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.
- **autoplay** (`boolean`, default `true`): While 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.
- **className** (`string`): Extra class for the root, a stage of its own.

## Accessibility

- 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.

## Source

### docs-sidebar/docs-sidebar.tsx

```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
 *   transform. The open section's pages are revealed from the top with a clip-path while the
 *   sections below slide down in step, and the closing section's pages fold away the same way.
 * - The tree is an SVG per section: one branch per page, from the top of the trunk down to the
 *   page's rounded elbow. The branches are drawn twice, in grey and in the section's colour; only
 *   the lit page's coloured branch shows, so its path from the title lights up.
 * - The page under the pointer lights up (white and bold, its icon and branch in the section's
 *   colour) and stays lit until another one does; when the pointer leaves the sidebar, the light
 *   goes back to the current page.
 * - The mark on the rail slides to the open section's title and takes its colour.
 * - Sections are disclosure buttons; the arrow keys move through titles and pages. Under reduced
 *   motion, sections switch at once. While on screen and untouched, a ghost pointer browses it.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets. On light, the sections' colours are
 *   deepened a little (color-mix with black), so they hold on white.
 */

/** The layout, in px: a title's row, a page's row, and the room around them. */
const TITLE = 46;
const ROW = 36;
/** Above the first title and below the last one, inside the rail. */
const ABOVE = 8;
const BELOW = 17;
/** Between a title and its first page, and after the last page. */
const PAGES_TOP = 4;
const PAGES_BOTTOM = 9;
/**
 * The tree: its trunk's x, where the trunk starts, how far below a page's middle its elbow
 * turns, the elbow's radius, and where the branch ends.
 */
const TRUNK = 35.5;
const TRUNK_TOP = 11.5;
const ELBOW = 7.5;
const RADIUS = 6;
const BRANCH_END = 50;
/** The mark on the rail, centred on the open title. */
const MARK = 32;

/** The height of a section's pages, open. */
const pagesHeight = (section: Section) => PAGES_TOP + section.pages.length * ROW + PAGES_BOTTOM;

/** The branch from the top of the trunk to page `k`'s elbow. */
const branch = (k: number) => {
  const y = PAGES_TOP + k * ROW + ROW / 2 + ELBOW;
  return `M${TRUNK} ${TRUNK_TOP}V${y - RADIUS}A${RADIUS} ${RADIUS} 0 0 0 ${TRUNK + RADIUS} ${y}H${BRANCH_END}`;
};

/** Where the light is: a page, by its section and its place in that section. */
type Spot = { section: number; page: number };

export function DocsSidebar({
  sections = DOCS,
  label = 'Documentation',
  defaultOpen,
  defaultPage,
  defaultHover,
  onSelect,
  autoplay = true,
  colorScheme,
  className = '',
}: {
  /** The sections, in order, each with its title, its colour and its pages. */
  sections?: Section[];
  /** The navigation's accessible name. */
  label?: string;
  /** The id of the section open at first; the first section by default. */
  defaultOpen?: string;
  /** The id of the current page at first; the first page of the open section by default. */
  defaultPage?: string;
  /**
   * The id of a page drawn lit as if the pointer were on it, at first (in the open section); it
   * turns the demo off. The light goes back to the current page when the pointer leaves.
   */
  defaultHover?: string;
  /** Called with a page's id when it is chosen. */
  onSelect?: (id: string) => void;
  /** While on screen and untouched, a ghost pointer browses the sections on its own. */
  autoplay?: boolean;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const { ref: root, playing, take } = useAutoplay<HTMLDivElement>(6000);
  const first = sections.findIndex((section) => section.id === defaultOpen);
  const [open, setOpen] = useState(Math.max(0, first));
  const [current, setCurrent] = useState(defaultPage ?? sections[Math.max(0, first)]?.pages[0]?.id);
  /** The page lit by the pointer or the focus: it stays lit until another one is. */
  const [hover, setHover] = useState<Spot | null>(() => {
    for (let section = 0; section < sections.length; section += 1) {
      const page = sections[section].pages.findIndex((item) => item.id === defaultHover);
      if (defaultHover !== undefined && page >= 0) return { section, page };
    }
    return null;
  });
  const [focused, setFocused] = useState(false);
  const [message, setMessage] = useState('');
  const baseId = useId();
  const nav = useRef<HTMLElement>(null);
  const pointer = useRef<SVGSVGElement>(null);
  const stopDemo = useRef<(() => void) | null>(null);

  /** Repeats are made to differ, so screen readers announce them again. */
  const announce = (text: string) => setMessage((now) => (now === text ? `${text}\u00a0` : text));

  // Each state's layout: where each title sits, how long the rail is, where the mark goes.
  const tops = sections.map(
    (_, i) => ABOVE + i * TITLE + (i > open ? pagesHeight(sections[open]) : 0),
  );
  const length = (i: number) => ABOVE + sections.length * TITLE + pagesHeight(sections[i]) + BELOW;
  const longest = Math.max(...sections.map((_, i) => length(i)));

  /** The page lit in the open section: the pointer's, else the current one, else the first. */
  const litPage = () => {
    if (hover?.section === open) return hover.page;
    const at = sections[open].pages.findIndex((page) => page.id === current);
    return Math.max(0, at);
  };
  const lit = litPage();

  const openSection = (i: number, byReader: boolean) => {
    if (i === open || !sections[i]) return;
    setOpen(i);
    setHover(null);
    if (byReader) announce(`${sections[i].title}, ${sections[i].pages.length} pages`);
  };

  const choose = (section: number, page: number) => {
    const chosen = sections[section].pages[page];
    setCurrent(chosen.id);
    onSelect?.(chosen.id);
    announce(`${chosen.label}, current page`);
  };

  /** Up and down through the titles and the open section's pages; Home and End go to the ends. */
  const onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
    const stops = Array.from(nav.current?.querySelectorAll('button') ?? []).filter(
      (button) => !button.closest('[inert]'),
    );
    const at = stops.findIndex((button) => button === document.activeElement);
    const moves: Record<string, number> = {
      ArrowDown: at + 1,
      ArrowUp: at - 1,
      Home: 0,
      End: stops.length - 1,
    };
    const target = event.key in moves ? stops[moves[event.key]] : undefined;
    if (!target) return;
    event.preventDefault();
    target.focus();
  };

  // The demo calls the latest handlers.
  const actions = useRef({ open, sections, openSection });
  useLayoutEffect(() => {
    actions.current = { open, sections, openSection };
  });

  // The demo: the ghost pointer rests on the open title, runs down its pages, and opens the next
  // section, again and again.
  const demo = autoplay && defaultHover === undefined && playing && !focused;
  useEffect(() => {
    const stage = root.current;
    const ghost = pointer.current;
    const box = nav.current;
    if (!demo || !stage || !ghost || !box) return;
    const titleOf = (i: number) => box.querySelectorAll('[data-title]')[i];
    const pageOf = (i: number, k: number) =>
      box.querySelectorAll('[data-pages]')[i]?.querySelectorAll('button')[k];
    const stopRun = runDemo(stage, ghost, async ({ wait, moveTo, press }) => {
      await wait(600);
      for (;;) {
        const { open: now, sections: all } = actions.current;
        await moveTo(titleOf(now), { x: 0.62, y: 0.55 });
        await wait(700);
        for (let k = 1; k < all[now].pages.length; k += 1) {
          await moveTo(pageOf(now, k), { x: 0.7, y: 0.62 });
          setHover({ section: now, page: k });
          await wait(900);
        }
        const next = (now + 1) % all.length;
        await moveTo(titleOf(next), { x: 0.36, y: 0.6 });
        await wait(260);
        await press(() => actions.current.openSection(next, false));
        await wait(900);
      }
    });
    const stop = () => {
      stopRun();
      setHover(null);
    };
    stopDemo.current = stop;
    return stop;
  }, [demo, root]);

  /** The reader takes over: the demo stops at once and waits until they leave it alone. */
  const readerActs = () => {
    stopDemo.current?.();
    stopDemo.current = null;
    take();
  };

  /** Mouse and pen only: a finger does not hover. */
  const hovers = (event: PointerEvent) => event.pointerType !== 'touch';

  return (
    <div
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={readerActs}
      onKeyDownCapture={readerActs}
      onFocusCapture={() => {
        // Before the focused page lights up: stopping the demo puts the light out.
        readerActs();
        setFocused(true);
      }}
      onBlur={(event) => {
        if (!event.currentTarget.contains(event.relatedTarget)) setFocused(false);
      }}
    >
      <div className={styles.stage}>
        <nav
          ref={nav}
          aria-label={label}
          className={styles.nav}
          style={{ height: longest } as CSSProperties}
          onKeyDown={onKeyDown}
          onPointerMove={(event) => {
            if (hovers(event)) readerActs();
          }}
          onPointerLeave={(event) => {
            if (hovers(event)) setHover(null);
          }}
          onBlur={(event) => {
            if (!event.currentTarget.contains(event.relatedTarget)) setHover(null);
          }}
        >
          <span
            className={styles.rail}
            data-part="rail"
            style={{ transform: `scaleY(${length(open) / longest})` }}
            aria-hidden="true"
          />
          <span
            className={styles.mark}
            data-part="mark"
            style={
              {
                '--accent': sections[open].accent,
                transform: `translateY(${tops[open] + TITLE / 2 - MARK / 2}px)`,
              } as CSSProperties
            }
            aria-hidden="true"
          />
          <ul className={styles.sections}>
            {sections.map((section, s) => {
              const isOpen = s === open;
              const height = pagesHeight(section);
              return (
                <li
                  key={section.id}
                  className={styles.section}
                  data-part="section"
                  data-open={isOpen || undefined}
                  style={
                    {
                      '--accent': section.accent,
                      transform: `translateY(${tops[s]}px)`,
                    } as CSSProperties
                  }
                >
                  <button
                    type="button"
                    className={styles.title}
                    data-part="title"
                    data-title=""
                    aria-expanded={isOpen}
                    aria-controls={`${baseId}-${section.id}`}
                    aria-disabled={isOpen || undefined}
                    onClick={() => openSection(s, true)}
                  >
                    {section.title}
                  </button>
                  <div
                    id={`${baseId}-${section.id}`}
                    className={styles.pages}
                    style={{ height }}
                    data-pages=""
                    inert={!isOpen}
                  >
                    <svg
                      className={styles.tree}
                      data-part="tree"
                      width={BRANCH_END}
                      height={height}
                      aria-hidden="true"
                      // Sized inline too, so a page's own svg rule can't shrink its box.
                      style={{ width: BRANCH_END, height }}
                    >
                      {section.pages.map((page, k) => (
                        <path
                          key={page.id}
                          className={styles.branch}
                          data-part="branch"
                          d={branch(k)}
                        />
                      ))}
                      {section.pages.map((page, k) => (
                        <path
                          key={page.id}
                          className={styles.litBranch}
                          data-part="lit-branch"
                          data-lit={(isOpen && k === lit) || undefined}
                          d={branch(k)}
                        />
                      ))}
                    </svg>
                    <ul className={styles.list}>
                      {section.pages.map((page, k) => {
                        const Icon = page.icon;
                        return (
                          <li key={page.id}>
                            <button
                              type="button"
                              className={styles.page}
                              data-part="page"
                              data-lit={(isOpen && k === lit) || undefined}
                              aria-current={page.id === current ? 'page' : undefined}
                              onPointerEnter={(event) => {
                                if (!hovers(event)) return;
                                readerActs();
                                setHover({ section: s, page: k });
                              }}
                              onFocus={() => setHover({ section: s, page: k })}
                              onClick={() => choose(s, k)}
                            >
                              <span className={styles.icon} data-part="icon">
                                <Icon />
                              </span>
                              {page.label}
                            </button>
                          </li>
                        );
                      })}
                    </ul>
                  </div>
                </li>
              );
            })}
          </ul>
        </nav>
      </div>
      <GhostPointer ref={pointer} />
      <p className={styles.hidden} role="status">
        {message}
      </p>
    </div>
  );
}
```

### docs-sidebar/docs-sidebar.module.css

```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;
  --focus: rgb(0 0 0 / 0.6);
  /* The demo's pointer: dark on the light stage, white on the black one. */
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --ease: cubic-bezier(0.32, 0.72, 0, 1);
  --switch: 0.45s;
  position: relative;
  container-type: inline-size;
  overflow: hidden;
  overflow: clip;
  border-radius: 18px;
  background: var(--surface);
  box-shadow: inset 0 0 0 1px var(--edge);
  color: var(--ink);
  font-family:
    Inter,
    -apple-system,
    BlinkMacSystemFont,
    'Segoe UI',
    sans-serif;
  -webkit-font-smoothing: antialiased;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --surface: light-dark(#fff, #000);
    --edge: light-dark(rgb(0 0 0 / 0.08), rgb(0 0 0 / 0.06));
    --ink: light-dark(#171717, #fff);
    --ink-muted: light-dark(#666, #888);
    --page-icon: light-dark(#8a8a8a, #5f5f5f);
    --dim: light-dark(#737373, #4e4e4e);
    --dim-hover: light-dark(#525252, #7a7a7a);
    --rail: light-dark(#e8e8e8, #212121);
    --line: light-dark(#e3e3e3, #262626);
    --focus: light-dark(rgb(0 0 0 / 0.6), rgb(255 255 255 / 0.6));
    --pointer: light-dark(#1c1c1e, #fff);
    --pointer-edge: light-dark(#fff, #000);
    --pointer-shadow: light-dark(rgb(0 0 0 / 0.25), rgb(0 0 0 / 0.5));
  }
}
/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}
.root *,
.root *::before,
.root *::after {
  box-sizing: border-box;
}
/* The buttons and lists start bare; :where() keeps this below every class. */
:where(.root) button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
}
:where(.root) ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.stage {
  display: grid;
  place-items: center;
  min-height: clamp(400px, 54cqi, 560px);
  padding: 40px 16px;
}
.nav {
  position: relative;
  width: 240px;
}

/* The section's colour (--accent, set on each section and on the mark) as the lit things wear it:
   as given at night; deepened on the white stage, where bright tones would be faint. */
.mark,
.section {
  --lit: color-mix(in oklab, var(--accent) 85%, #000);
}
@supports (color: light-dark(#fff, #000)) {
  /* The section's colour (--accent, set on each section and on the mark) as the lit things wear it:
   as given at night; deepened on the white stage, where bright tones would be faint. */
  .mark,
  .section {
    --lit: light-dark(color-mix(in oklab, var(--accent) 85%, #000), var(--accent));
  }
}

/* The rail, as long as the open state needs, and the mark that slides along it. */
.rail {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 100%;
  background: var(--rail);
  transform-origin: 0 0;
  transition: transform var(--switch) var(--ease);
}
.mark {
  position: absolute;
  top: 0;
  left: -0.5px;
  z-index: 1;
  width: 2px;
  height: 32px;
  background: var(--lit);
  transition:
    transform var(--switch) var(--ease),
    background-color var(--switch) var(--ease);
}

.sections {
  position: absolute;
  inset: 0;
}
.section {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  transition: transform var(--switch) var(--ease);
}

.title {
  display: flex;
  align-items: center;
  height: 46px;
  margin-left: 27px;
  padding: 0 8px;
  border-radius: 8px;
  font-size: 18px;
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.01em;
  white-space: nowrap;
  color: var(--dim);
  cursor: pointer;
  transition: color 0.3s ease;
}
.section[data-open] .title {
  color: var(--ink);
  cursor: default;
}
@media (hover: hover) {
  .section:not([data-open]) .title:hover {
    color: var(--dim-hover);
  }
}

/* A section's pages: revealed from the top as it opens, folded back up as it closes. The inset
   reaches past the sides so focus rings and long labels show. */
.pages {
  position: relative;
  clip-path: inset(-8px -100vw 100% -8px);
  opacity: 0;
  visibility: hidden;
  transition:
    clip-path var(--switch) var(--ease),
    opacity 0.25s ease,
    visibility 0s linear var(--switch);
}
.section[data-open] .pages {
  clip-path: inset(-8px -100vw -8px -8px);
  opacity: 1;
  visibility: visible;
  transition:
    clip-path var(--switch) var(--ease),
    opacity 0.3s ease 0.05s,
    visibility 0s;
}

.tree {
  position: absolute;
  top: 0;
  left: 0;
  overflow: visible;
  fill: none;
  stroke-width: 1;
}
.branch {
  stroke: var(--line);
}
.litBranch {
  stroke: var(--lit);
  opacity: 0;
  transition: opacity 0.2s ease;
}
.litBranch[data-lit] {
  opacity: 1;
}

.list {
  position: absolute;
  top: 4px;
  right: 0;
  left: 0;
}
.page {
  display: flex;
  align-items: center;
  gap: 8px;
  width: max-content;
  min-width: calc(100% - 52px);
  height: 36px;
  margin-left: 52px;
  padding: 0 8px;
  border-radius: 8px;
  font-size: 14px;
  line-height: 1;
  white-space: nowrap;
  color: var(--ink-muted);
  cursor: pointer;
  transition: color 0.15s ease;
}
.icon {
  display: grid;
  flex: none;
  width: 16px;
  height: 16px;
  color: var(--page-icon);
  transition: color 0.15s ease;
}
.icon svg {
  width: 100%;
  height: 100%;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.page[data-lit] {
  color: var(--ink);
  font-weight: 600;
}
.page[data-lit] .icon {
  color: var(--lit);
}

.title:focus-visible,
.page:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

.hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .root *,
  .root *::before,
  .root *::after {
    transition: none !important;
  }
}
```

### docs-sidebar/sections.ts

```tsx
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;
};

export type Section = {
  /** Unique across the sidebar. */
  id: string;
  title: string;
  /** The section's colour: the mark beside its title, its lit branch and its lit icon. */
  accent: string;
  pages: Page[];
};

export const DOCS: Section[] = [
  {
    id: 'sending',
    title: 'Sending & Delivery',
    accent: '#f3722c',
    pages: [
      { id: 'mail-api', label: 'Mail API', icon: EnvelopeIcon },
      { id: 'inline-css', label: 'Inline CSS supported', icon: BracesIcon },
      { id: 'event-hooks', label: 'Event hooks', icon: BoltIcon },
    ],
  },
  {
    id: 'reporting',
    title: 'Email Reporting',
    accent: '#5d7ffa',
    pages: [
      { id: 'overview', label: 'Overview', icon: BarsIcon },
      { id: 'opens-clicks', label: 'Opens & link clicks', icon: EyeIcon },
      { id: 'bounces', label: 'Bounces & complaints', icon: AlertIcon },
    ],
  },
  {
    id: 'templates',
    title: 'Reusable Templates',
    accent: '#9b74f6',
    pages: [
      { id: 'blocks', label: 'Drag-and-drop blocks', icon: BlocksIcon },
      { id: 'code-editor', label: 'Live code editor', icon: PenIcon },
      { id: 'revisions', label: 'Revision history', icon: HistoryIcon },
    ],
  },
];
```

### docs-sidebar/icons.tsx

```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>
  );
}

export function BracesIcon() {
  return (
    <Icon>
      <path d="M8.5 4.5C6.6 4.5 6 5.4 6 7v2.6c0 1.2-.7 2-2 2.4 1.3.4 2 1.2 2 2.4V17c0 1.6.6 2.5 2.5 2.5" />
      <path d="M15.5 4.5c1.9 0 2.5.9 2.5 2.5v2.6c0 1.2.7 2 2 2.4-1.3.4-2 1.2-2 2.4V17c0 1.6-.6 2.5-2.5 2.5" />
    </Icon>
  );
}

export function BoltIcon() {
  return (
    <Icon>
      <path d="M13.5 3 5.5 13.5h6l-1 7.5 8-10.5h-6z" />
    </Icon>
  );
}

export function BarsIcon() {
  return (
    <Icon>
      <path d="M3.5 20.5h17M7 17v-5M12 17V6M17 17V9.5" />
    </Icon>
  );
}

export function EyeIcon() {
  return (
    <Icon>
      <path d="M2.5 12s3.5-6.5 9.5-6.5 9.5 6.5 9.5 6.5-3.5 6.5-9.5 6.5S2.5 12 2.5 12z" />
      <circle cx="12" cy="12" r="2.75" />
    </Icon>
  );
}

export function AlertIcon() {
  return (
    <Icon>
      <circle cx="12" cy="12" r="8.75" />
      <path d="M12 7.75v5M12 16.25v.01" />
    </Icon>
  );
}

export function BlocksIcon() {
  return (
    <Icon>
      <rect x="4" y="4" width="6.75" height="6.75" rx="1.5" />
      <rect x="13.25" y="4" width="6.75" height="6.75" rx="1.5" />
      <rect x="4" y="13.25" width="6.75" height="6.75" rx="1.5" />
      <rect x="13.25" y="13.25" width="6.75" height="6.75" rx="1.5" />
    </Icon>
  );
}

export function PenIcon() {
  return (
    <Icon>
      <path d="M15 4.5 19.5 9 9 19.5H4.5V15z" />
      <path d="m12.5 7 4.5 4.5" />
    </Icon>
  );
}

export function HistoryIcon() {
  return (
    <Icon>
      <path d="M3.75 12a8.25 8.25 0 1 0 2.4-5.8" />
      <path d="M3.75 4.5v4h4" />
      <path d="M12 8v4.25l2.75 1.75" />
    </Icon>
  );
}
```

### docs-sidebar/ghost-pointer.tsx

```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 {}

type Point = { x: number; y: number };

/** Where the arrow's tip is, in its 20 × 20 box. */
const TIP: Point = { x: 3, y: 2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: SVGSVGElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.8, y: stage.offsetHeight * 0.9 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen pixels to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    const to = {
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    };
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(360, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const press: Demo['press'] = async (action) => {
    const arrow = pointer.firstElementChild;
    const tap = arrow?.animate(
      [{ transform: 'none' }, { transform: 'scale(0.8)', offset: 0.35 }, { transform: 'none' }],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** Its colours are the stage's tokens (docs-sidebar.module.css). */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  width: 22,
  height: 22,
  overflow: 'visible',
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  fill: 'var(--pointer)',
  stroke: 'var(--pointer-edge)',
  strokeWidth: 1.3,
  strokeLinejoin: 'round',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
};

/**
 * The arrow the demo moves around, dark on the white stage and white on the black one. It is
 * decorative: the demo is silent.
 */
export function GhostPointer({ ref }: { ref: Ref<SVGSVGElement> }) {
  return (
    <svg ref={ref} viewBox="0 0 20 20" style={POINTER} aria-hidden="true">
      <path
        d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
        style={{ transformBox: 'fill-box', transformOrigin: '0 0' }}
      />
    </svg>
  );
}
```

### docs-sidebar/use-autoplay.ts

```tsx
'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;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```