# Sidebar nav

> The left rail of a chat app: a workspace switcher, a collapsible Chats group with search, and a hover that glides from row to row.

A workspace sidebar. A switcher with its menu and a collapse button across its head, three actions under it, the chats below with their own search, and an Upgrade button at its foot. Run the pointer down any list and one highlight slides along behind the rows; collapse it and the rail narrows to its icons while every label fades away.

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

Category: AI.

## Anatomy

1. **Workspace switcher**: The logo and the workspace’s name. It opens the workspace menu; ArrowDown opens it too, with focus on its first entry.
2. **Collapse button**: Narrows the rail from 224 to 52 px in 280 ms. An expand button takes its place, and focus moves to it.
3. **Action row**: An 18 px icon and a label. Collapsed, only the icon is left and the label becomes its tooltip.
4. **Highlight**: One per list, behind its rows. It glides by top and height in 220 ms to the row under the pointer or keyboard focus.
5. **Chats heading**: Folds the group away and back. The list’s height closes over 220 ms and the chevron turns.
6. **Search button**: Grows into a field across the row in 180 ms. Typing filters the chats, and Escape closes it.
7. **Picked chat**: The current page, with `aria-current="page"` and a fill that gives way to the highlight while the pointer is in its list.
8. **Upgrade button**: One call to action at the foot, under a rule, away from the navigation.

## When to use

- The main navigation of a chat or workspace app: a few fixed actions above a long, growing list of conversations.
- Apps with more than one workspace, where the switcher at the head names the current one and opens the others.
- A history long enough to need a search of its own, kept beside the list instead of in a separate page.
- Layouts that must give the content more room on demand: the rail collapses to its 52 px of icons and back.

## When not to use

- Fewer than five destinations and no history. Use tabs or a top bar.
- Phones as the only layout. A 224 px rail takes most of the screen; use a drawer or a bottom tab bar.
- Deep trees of folders and pages. The Chats group is one flat list; use a tree view with nested disclosure.
- Settings or a long form split into sections. Use a table of contents or in-page tabs.
- Searching across the whole product. Its search filters chat titles only; use a command palette.

## Installation

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

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

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 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 { SidebarNav } from './sidebar-nav';

export function AppSidebar({ route, go }: { route: string; go: (id: string) => void }) {
  return (
    <SidebarNav
      autoplay={false}
      workspace="Creamery Ops"
      chats={[
        { id: 'supplier-records', title: 'Supplier records' },
        { id: 'workload', title: 'Workload summary' },
      ]}
      active={route}
      onSelect={go}
      onMenuAction={(action) => console.log(action)}
      onUpgrade={() => go('billing')}
    />
  );
}
```

## States

- **Default**: Nothing picked yet: the actions, the eight newest chats and the foot.
- **Pointed at**: The highlight stands under the row. Moving on, it glides there instead of blinking.
- **Picked**: The chat that is open keeps a fill and `aria-current="page"`.
- **Workspace menu**: Pops in from its top-left corner under the switcher: the current workspace with a check, then its actions.
- **Searching**: The field has taken the heading’s place, and the list keeps only the chats that match `sup`.
- **No results**: A search that matches nothing says so in a quiet line, which is also announced.
- **Chats folded**: The group is closed to its heading, and the chevron points at the label.
- **Collapsed**: Icons only, 52 px wide. The chats and the foot are hidden and inert until it expands.

## How it works

- Each list (the actions, the chats, the workspace menu) has one highlight of its own, behind its rows. The row under the pointer, or under keyboard focus, sets its top and height; transitions of 220 ms on cubic-bezier(0.23, 1, 0.32, 1) make it glide from row to row. Leaving the list fades it out in 150 ms where it stands, so the next visit glides on from there.
- The highlight is placed by listeners on the list, wired through a callback ref, so moving it never re-renders the rows; the menu, which mounts later, gets wired as it appears.
- The picked row keeps a fill of the same colour, which gives way to the highlight while the pointer is in its list, so the two are never seen at once.
- Collapsing narrows the rail from 224 to 52 px over 280 ms on cubic-bezier(0.16, 1, 0.3, 1). The content keeps its 224 px and the rail’s edge cuts it; the rows narrow to 36 px around their icons while every label fades in 180 ms and drifts 8 px left. The logo makes way for the expand button in the same 180 ms. The rail stays centred on its stage as it narrows.
- The workspace menu hangs 6 px under the switcher, kept inside the stage on a narrow screen. It pops in from its top-left corner, from 0.95 and transparent, in 180 ms, and closes at once.
- Search grows out of its button: the field widens from 28 px to the row in 180 ms while the Chats label fades and slips 4 px left. The list filters on every key; Escape or the cross clears it and closes the field.
- The Chats label folds the group: a grid row closes from 1fr to 0fr over 220 ms and the chevron turns to point at the label.
- While on screen and untouched, a demo pointer runs down the rows, picks a chat, searches for “sup”, folds the chats, goes through the workspace menu and collapses the rail. Moving the mouse over it, a press or a key hands it to the reader.
- Every colour is a light-dark() token on the root, so the sidebar follows the page’s color-scheme or the colorScheme prop.

## API reference

- **workspace** (`string`, default `'Creamery Ops'`): The workspace’s name, on the switcher and atop its menu.
- **items** (`SidebarItem[]`): The actions under the switcher: an id, a label, an icon (new-chat, home, invite, or your own node) and an optional badge.
- **chats** (`SidebarChat[]`): The chats in the Chats group, each an id and a title.
- **active** (`string | null`): The picked row’s id; with it, the pick is controlled (pair it with onSelect).
- **defaultActive** (`string | null`, default `null`): The row picked first, when uncontrolled.
- **onSelect** (`(id: string) => void`): Called with the id of the action or chat pressed.
- **collapsed** (`boolean`): Whether the rail is narrowed to its icons; with it, collapsing is controlled.
- **defaultCollapsed** (`boolean`, default `false`): Whether it starts narrowed, when uncontrolled.
- **onCollapsedChange** (`(collapsed: boolean) => void`): Called with the new state when the collapse or expand button is pressed.
- **defaultState** (`SidebarNavState`): Opens held still, with no demo: menu (the workspace menu open, until the sidebar itself is used), search (the chat search with these words), folded (Chats folded away) and hover (the row the highlight stands on).
- **onMenuAction** (`(action: 'new-workspace' | 'settings' | 'invite-members' | 'sign-out') => void`): Called with the workspace menu’s entry picked.
- **upgradeLabel** (`string`, default `'Upgrade'`): The foot button’s label.
- **onUpgrade** (`() => void`): Called when the foot button is pressed.
- **autoplay** (`boolean`, default `true`): While on screen and untouched, a demo pointer goes through the sidebar by itself.
- **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.

## Accessibility

- The sidebar is a nav named Workspace; every row is a button, and the picked one is aria-current="page".
- The highlight follows keyboard focus as it follows the pointer, and every control also shows a focus ring.
- The switcher is a menu button (aria-haspopup, aria-expanded). Its menu takes focus on its first entry; arrow keys, Home and End move through it, Escape closes it and returns focus to the switcher, Tab closes it.
- Collapse and expand are named buttons; pressing one moves focus to the other as they swap. While narrowed, the hidden switcher, chats and foot are inert, and each action keeps its label for screen readers and as a tooltip.
- The Chats label is a disclosure button (aria-expanded) for the list. The search field is labelled; Escape clears and closes it and focus returns to its button. A polite live region says how many chats were found.
- Under reduced motion the demo never plays and everything changes without animation.

## Source

### sidebar-nav/sidebar-nav.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import {
  useCallback,
  useEffect,
  useEffectEvent,
  useId,
  useLayoutEffect,
  useRef,
  useState,
  type KeyboardEvent,
  type PointerEvent as ReactPointerEvent,
  type ReactNode,
} from 'react';
import { DemoPointer, runDemo, type Demo, type Point } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './sidebar-nav.module.css';

/*
 * Sidebar nav: the left rail of a chat app. A workspace switcher and a collapse button across its
 * head, three actions (New chat, Home, Invite users), the Chats group with its search, and an
 * Upgrade button at its foot.
 *
 * How it moves
 * - The gliding hover. Each list (the actions, the chats, the workspace menu) has one highlight
 *   of its own, behind its rows. The row under the pointer, or the one keyboard focus is on,
 *   sends it there: it slides its top and height over 220 ms on a steep ease-out, so moving down
 *   the list it glides from row to row instead of blinking. Leaving the list fades it out in
 *   150 ms where it stands, and the next visit glides on from there. The picked row keeps a fill
 *   of the same colour, which gives way to the highlight while the pointer is in its list.
 * - Collapsing narrows the rail from 224 to 52 px over 280 ms on cubic-bezier(0.16, 1, 0.3, 1):
 *   the rows narrow to their icons, while every label fades out in 180 ms and drifts 8 px left.
 *   The logo makes way for the expand button in the same 180 ms. Expanding plays it back.
 * - The workspace menu pops in from its top-left corner: 0.95 to full size and transparent to
 *   opaque in 180 ms. It closes at once, as menus do.
 * - Search grows out of its button: the field widens from 28 px to the whole row in 180 ms while
 *   the Chats label fades and slips 4 px left. The list filters as one types.
 * - The Chats label folds its group away: the list's height closes over 220 ms, the chevron
 *   turning to point at the label.
 * - The demo, while on screen and untouched, runs a pointer down the rows, picks a chat, searches,
 *   folds the chats, opens the workspace menu and collapses the rail. Any input hands it to the
 *   reader.
 * - 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.
 */

export type SidebarIcon = 'new-chat' | 'home' | 'invite';

export type SidebarItem = {
  id: string;
  label: string;
  /** One of the drawn icons, or your own 18 px node. */
  icon: SidebarIcon | ReactNode;
  /** A short note at the row's end, "3/10". */
  badge?: string;
};

export type SidebarChat = { id: string; title: string };

export type SidebarMenuAction = 'new-workspace' | 'settings' | 'invite-members' | 'sign-out';

/** A state to open in, held still: any of it turns the demo off. */
export type SidebarNavState = {
  /** The workspace menu open (it stays open until the sidebar itself is used). */
  menu?: boolean;
  /** The chat search open with these words typed ('' for the empty field). */
  search?: string;
  /** The Chats group folded away. */
  folded?: boolean;
  /** The row (an action's or a chat's id) the highlight stands on, as under the pointer. */
  hover?: string;
};

const SAMPLE_ITEMS: SidebarItem[] = [
  { id: 'new-chat', label: 'New chat', icon: 'new-chat' },
  { id: 'home', label: 'Home', icon: 'home' },
  { id: 'invite', label: 'Invite users', icon: 'invite', badge: '3/10' },
];

const SAMPLE_CHATS: SidebarChat[] = [
  { id: 'supplier-records', title: 'Supplier records' },
  { id: 'urgent-todos', title: 'Urgent to-dos this morning' },
  { id: 'flavor-ticket', title: 'Flavor page ticket' },
  { id: 'workload', title: 'Workload summary' },
  { id: 'off-board', title: 'Off-board a supplier' },
  { id: 'restock', title: 'Batch restock function' },
  { id: 'flavor-edits', title: 'Propose flavor edits' },
  { id: 'waffle-cones', title: 'Waffle cone sourcing' },
];

const MENU: { id: SidebarMenuAction; label: string; icon: ReactNode; rule?: boolean }[] = [
  { id: 'new-workspace', label: 'New workspace', icon: <PlusIcon /> },
  { id: 'settings', label: 'Workspace settings', icon: <SettingsIcon /> },
  { id: 'invite-members', label: 'Invite team members', icon: <InviteIcon size={16} /> },
  { id: 'sign-out', label: 'Sign out', icon: <SignOutIcon />, rule: true },
];

/** The menu's width and its gap under the switcher, in px. */
const MENU_WIDTH = 256;
const MENU_GAP = 6;

/** The demo's search, typed a letter at a time. */
const DEMO_QUERY = 'sup';

/* ----------------------------------------------------------------------------------------- */
/* The gliding highlight                                                                       */
/* ----------------------------------------------------------------------------------------- */

type Glide = { show: (item: HTMLElement) => void; hide: () => void };

/** Each list's highlight, by its list, so the demo's pointer can move it as a real one would. */
const glides = new WeakMap<Element, Glide>();

/**
 * One list's highlight. The list listens for the pointer over its rows ([data-glide-item]) and
 * for keyboard focus on them, and sets the highlight's top and height; the stylesheet's
 * transitions make it glide. It never re-renders the list.
 */
function useGlide() {
  const group = useRef<HTMLDivElement | null>(null);

  // A callback ref, so a list that mounts later (the menu) is wired as it appears.
  const attach = useCallback((list: HTMLDivElement | null) => {
    group.current = list;
    const highlight = list?.querySelector<HTMLElement>('[data-glide-mark]');
    if (!list || !highlight) return;
    const show = (item: HTMLElement) => {
      highlight.style.top = `${item.offsetTop}px`;
      highlight.style.height = `${item.offsetHeight}px`;
      highlight.style.opacity = '1';
      list.dataset.gliding = '';
    };
    const hide = () => {
      highlight.style.opacity = '0';
      delete list.dataset.gliding;
    };
    const itemOf = (target: EventTarget | null) =>
      target instanceof Element ? target.closest<HTMLElement>('[data-glide-item]') : null;
    const over = (event: PointerEvent) => {
      if (event.pointerType === 'touch') return;
      const item = itemOf(event.target);
      if (item && list.contains(item)) show(item);
    };
    // Leaving the list hands the highlight back to the row keyboard focus is on, if any.
    const leave = (event: PointerEvent) => {
      if (event.pointerType === 'touch') return;
      const focused = itemOf(document.activeElement);
      if (focused && list.contains(focused) && focused.matches(':focus-visible')) show(focused);
      else hide();
    };
    const focusIn = (event: FocusEvent) => {
      const item = itemOf(event.target);
      if (item?.matches(':focus-visible')) show(item);
    };
    const focusOut = (event: FocusEvent) => {
      const next = event.relatedTarget;
      if (!(next instanceof Node && list.contains(next)) && !list.matches(':hover')) hide();
    };
    list.addEventListener('pointerover', over);
    list.addEventListener('pointerleave', leave);
    list.addEventListener('focusin', focusIn);
    list.addEventListener('focusout', focusOut);
    glides.set(list, { show, hide });
    return () => {
      list.removeEventListener('pointerover', over);
      list.removeEventListener('pointerleave', leave);
      list.removeEventListener('focusin', focusIn);
      list.removeEventListener('focusout', focusOut);
      glides.delete(list);
      group.current = null;
    };
  }, []);

  const hide = useCallback(() => {
    if (group.current) glides.get(group.current)?.hide();
  }, []);
  return { attach, hide };
}

/* ----------------------------------------------------------------------------------------- */
/* The sidebar                                                                                 */
/* ----------------------------------------------------------------------------------------- */

export function SidebarNav({
  workspace = 'Creamery Ops',
  items = SAMPLE_ITEMS,
  chats = SAMPLE_CHATS,
  active,
  defaultActive = null,
  onSelect,
  collapsed,
  defaultCollapsed = false,
  onCollapsedChange,
  onMenuAction,
  upgradeLabel = 'Upgrade',
  onUpgrade,
  autoplay = true,
  defaultState,
  colorScheme,
  className,
}: {
  /** The workspace's name, on the switcher and atop its menu. */
  workspace?: string;
  /** The actions under the switcher, each an icon and a label. */
  items?: SidebarItem[];
  /** The chats in the Chats group, newest first. */
  chats?: SidebarChat[];
  /** The picked row's id (an action's or a chat's); with it, the pick is controlled. */
  active?: string | null;
  /** The row picked first, when uncontrolled. */
  defaultActive?: string | null;
  /** Called with the id of the row pressed. */
  onSelect?: (id: string) => void;
  /** Whether the rail is narrowed to its icons; with it, collapsing is controlled. */
  collapsed?: boolean;
  /** Whether it starts narrowed, when uncontrolled. */
  defaultCollapsed?: boolean;
  /** Called with the new state when the collapse or expand button is pressed. */
  onCollapsedChange?: (collapsed: boolean) => void;
  /** Called with the workspace menu's item picked. */
  onMenuAction?: (action: SidebarMenuAction) => void;
  /** The foot button's label. */
  upgradeLabel?: string;
  onUpgrade?: () => void;
  /** While on screen and untouched, a demo pointer goes through the sidebar by itself. */
  autoplay?: boolean;
  /** Opens in this state, held still, with no demo: a menu open, a search typed, a row pointed at. */
  defaultState?: SidebarNavState;
  /** 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>(7000);
  const pointer = useRef<HTMLSpanElement>(null);
  const id = useId();
  const nav = useGlide();
  const list = useGlide();
  const menuGlide = useGlide();

  const trigger = useRef<HTMLButtonElement>(null);
  const collapseButton = useRef<HTMLButtonElement>(null);
  const expandButton = useRef<HTMLButtonElement>(null);
  const searchButton = useRef<HTMLButtonElement>(null);
  const input = useRef<HTMLInputElement>(null);
  const menu = useRef<HTMLDivElement>(null);

  const [ownActive, setOwnActive] = useState(defaultActive);
  const picked = active === undefined ? ownActive : active;
  const select = (next: string) => {
    if (active === undefined) setOwnActive(next);
    onSelect?.(next);
  };

  const [ownCollapsed, setOwnCollapsed] = useState(defaultCollapsed);
  const narrow = collapsed ?? ownCollapsed;
  const setNarrow = (next: boolean) => {
    if (collapsed === undefined) setOwnCollapsed(next);
    onCollapsedChange?.(next);
    if (next) setMenuOpen(false);
  };

  const [menuOpen, setMenuOpen] = useState(defaultState?.menu ?? false);
  const [menuAt, setMenuAt] = useState({ left: 0, top: 0 });
  const [searching, setSearching] = useState(defaultState?.search !== undefined);
  const [query, setQuery] = useState(defaultState?.search ?? '');
  const [chatsOpen, setChatsOpen] = useState(!defaultState?.folded);
  /** Whether opening the menu or the search should take focus: not when the demo opens them, nor
   *  a default state. */
  const takeFocus = useRef(!defaultState);
  /** A menu opened by defaultState stays open through presses elsewhere until the sidebar is used. */
  const pinned = useRef(!!defaultState?.menu);

  const needle = query.trim().toLowerCase();
  const shown = needle ? chats.filter((chat) => chat.title.toLowerCase().includes(needle)) : chats;
  const folded = !chatsOpen && !searching;

  // The highlight stands on rows that may have gone: a new filter sends it away.
  const hideList = list.hide;
  useEffect(() => hideList(), [needle, hideList]);

  // A default state's pointed-at row: its list's highlight is sent there once, as on a hover.
  const hoverId = defaultState?.hover;
  useEffect(() => {
    const row = hoverId
      ? root.current?.querySelector<HTMLElement>(
          `[data-glide-item][data-id="${CSS.escape(hoverId)}"]`,
        )
      : null;
    const group = row?.closest('[data-glide]');
    if (row && group) glides.get(group)?.show(row);
  }, [hoverId, root]);

  // The menu hangs 6 px under the switcher, from its left edge, kept inside the stage.
  useLayoutEffect(() => {
    if (!menuOpen) return;
    const stage = root.current;
    const button = trigger.current;
    if (!stage || !button) return;
    const frame = stage.getBoundingClientRect();
    const scale = frame.width / stage.offsetWidth || 1;
    const box = button.getBoundingClientRect();
    const left = (box.left - frame.left) / scale;
    const room = stage.offsetWidth - MENU_WIDTH - 8;
    setMenuAt({
      left: Math.max(8, Math.min(left, room)),
      top: (box.bottom - frame.top) / scale + MENU_GAP,
    });
    if (takeFocus.current) menu.current?.querySelector<HTMLElement>('[role^="menuitem"]')?.focus();
  }, [menuOpen, root]);

  // A press anywhere outside the menu and its switcher closes it.
  useEffect(() => {
    if (!menuOpen) return;
    const away = (event: PointerEvent) => {
      if (pinned.current) return;
      const target = event.target as Node;
      if (menu.current?.contains(target) || trigger.current?.contains(target)) return;
      setMenuOpen(false);
    };
    document.addEventListener('pointerdown', away);
    return () => document.removeEventListener('pointerdown', away);
  }, [menuOpen]);

  // Focus goes into the field as it opens, and back to the button once it is closed (the
  // button is inert while the field is open, so this waits for the render).
  const refocusSearch = useRef(false);
  useEffect(() => {
    if (searching && takeFocus.current) input.current?.focus({ preventScroll: true });
    if (!searching && refocusSearch.current) {
      refocusSearch.current = false;
      searchButton.current?.focus({ preventScroll: true });
    }
  }, [searching]);

  const openMenu = (focus: boolean) => {
    takeFocus.current = focus;
    setMenuOpen(true);
  };
  const closeMenu = (focusTrigger: boolean) => {
    setMenuOpen(false);
    if (focusTrigger) trigger.current?.focus({ preventScroll: true });
  };
  const openSearch = (focus: boolean) => {
    takeFocus.current = focus;
    setSearching(true);
  };
  const closeSearch = (focus: boolean) => {
    setSearching(false);
    setQuery('');
    refocusSearch.current = focus;
  };

  /** Collapse or expand; focus follows to the button that takes the pressed one's place. */
  const toggleNarrow = (next: boolean) => {
    const from = next ? collapseButton.current : expandButton.current;
    const hadFocus = from !== null && document.activeElement === from;
    setNarrow(next);
    if (hadFocus) {
      requestAnimationFrame(() =>
        (next ? expandButton : collapseButton).current?.focus({ preventScroll: true }),
      );
    }
  };

  const onMenuKey = (event: KeyboardEvent<HTMLDivElement>) => {
    const entries = [...(menu.current?.querySelectorAll<HTMLElement>('[role^="menuitem"]') ?? [])];
    const at = entries.indexOf(document.activeElement as HTMLElement);
    const go = (index: number) => entries[(index + entries.length) % entries.length]?.focus();
    if (event.key === 'ArrowDown') go(at + 1);
    else if (event.key === 'ArrowUp') go(at - 1);
    else if (event.key === 'Home') go(0);
    else if (event.key === 'End') go(entries.length - 1);
    else if (event.key === 'Escape') closeMenu(true);
    else if (event.key === 'Tab') setMenuOpen(false);
    else return;
    if (event.key !== 'Tab') event.preventDefault();
  };

  const onTriggerKey = (event: KeyboardEvent<HTMLButtonElement>) => {
    if (event.key === 'ArrowDown' && !menuOpen) {
      event.preventDefault();
      openMenu(true);
    }
  };

  const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
    if (event.key === 'Escape') {
      event.preventDefault();
      closeSearch(true);
    }
  };

  // Moving the mouse over the stage is using it: the demo hands over, as on a press.
  const onPointerMove = (event: ReactPointerEvent) => {
    if (event.pointerType === 'mouse' && (event.movementX || event.movementY)) take();
  };
  /** A press or a key in the sidebar: the reader's now, and a pinned menu closes as menus do. */
  const use = () => {
    pinned.current = false;
    take();
  };

  /* --------------------------------------------------------------------------------------- */
  /* The demo                                                                                  */
  /* --------------------------------------------------------------------------------------- */

  const demoSelect = useEffectEvent((next: string) => select(next));
  const demoNarrow = useEffectEvent((next: boolean) => setNarrow(next));
  const demoReset = useEffectEvent(() => {
    setMenuOpen(false);
    setSearching(false);
    setQuery('');
    setChatsOpen(true);
    if (narrow) setNarrow(false);
  });
  const demoChat = useEffectEvent((index: number) => chats[index]?.id);
  const demo = autoplay && !defaultState && playing;
  useEffect(() => {
    const stage = root.current;
    const arrow = pointer.current;
    if (!demo || !stage || !arrow) return;
    const find = (selector: string) => stage.querySelector<HTMLElement>(selector);
    const findAll = (selector: string) => [...stage.querySelectorAll<HTMLElement>(selector)];

    let hovered: HTMLElement | null = null;
    let gliding: Glide | null = null;
    /** Stands in for :hover, as the demo's pointer is not the reader's. */
    const hover = (element: HTMLElement | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = null;
      const group = element?.matches('[data-glide-item]') ? element.closest('[data-glide]') : null;
      const next = group ? (glides.get(group) ?? null) : null;
      if (gliding && gliding !== next) gliding.hide();
      gliding = next;
      if (next && element) next.show(element);
      else if (element) {
        hovered = element;
        element.setAttribute('data-hover', '');
      }
    };
    const visit = async ({ moveTo }: Demo, element: HTMLElement | null, at?: Point) => {
      await moveTo(element, at);
      hover(element);
    };

    const stop = runDemo(stage, arrow, async (steps) => {
      const { wait, press, moveTo } = steps;
      demoReset();
      await wait(1200);
      for (;;) {
        // Down the actions and the chats: the highlight glides along.
        for (const row of findAll('[data-glide="nav"] [data-glide-item]')) {
          await visit(steps, row, { x: 0.3, y: 0.5 });
          await wait(240);
        }
        const chatRows = findAll('[data-glide="chats"] [data-glide-item]');
        for (const row of chatRows.slice(0, 4)) {
          await visit(steps, row, { x: 0.3, y: 0.5 });
          await wait(240);
        }
        await wait(260);
        const pick = demoChat(3);
        if (pick) await press(() => demoSelect(pick));
        await wait(1000);

        // Search the chats, a letter at a time.
        await visit(steps, find('[data-part="search"]'));
        await wait(320);
        await press(() => {
          takeFocus.current = false;
          setSearching(true);
        });
        hover(null);
        await wait(520);
        for (let n = 1; n <= DEMO_QUERY.length; n += 1) {
          setQuery(DEMO_QUERY.slice(0, n));
          await wait(150);
        }
        await wait(1500);
        await visit(steps, find('[data-part="search-close"]'));
        await wait(260);
        await press(() => {
          setSearching(false);
          setQuery('');
        });
        hover(null);
        await wait(800);

        // Fold the chats away and back.
        await visit(steps, find('[data-part="chats"]'), { x: 0.16, y: 0.5 });
        await wait(320);
        await press(() => setChatsOpen(false));
        await wait(1100);
        await press(() => setChatsOpen(true));
        await wait(900);

        // The workspace menu, its highlight gliding down the entries.
        await visit(steps, find('[data-part="workspace"]'), { x: 0.42, y: 0.5 });
        await wait(320);
        await press(() => {
          takeFocus.current = false;
          setMenuOpen(true);
        });
        await wait(560);
        for (const entry of findAll('[data-glide="menu"] [data-glide-item]').slice(1, 4)) {
          await visit(steps, entry, { x: 0.36, y: 0.5 });
          await wait(380);
        }
        await wait(500);
        await visit(steps, find('[data-part="workspace"]'), { x: 0.42, y: 0.5 });
        await wait(200);
        await press(() => setMenuOpen(false));
        await wait(700);

        // Collapse to the icons, glide down them, and open it again.
        await visit(steps, find('[data-part="collapse"]'));
        await wait(320);
        await press(() => demoNarrow(true));
        hover(null);
        await wait(900);
        for (const row of findAll('[data-glide="nav"] [data-glide-item]')) {
          await visit(steps, row);
          await wait(320);
        }
        await visit(steps, find('[data-part="expand"]'));
        await wait(320);
        await press(() => demoNarrow(false));
        hover(null);
        await wait(700);
        await moveTo(stage, { x: 0.84, y: 0.9 });
        await wait(2400);
      }
    });
    return () => {
      stop();
      hover(null);
    };
  }, [demo, root]);

  /* --------------------------------------------------------------------------------------- */

  return (
    <div
      ref={root}
      className={`${styles.root} ${className ?? ''}`}
      data-color-scheme={colorScheme}
      onPointerDownCapture={use}
      onKeyDownCapture={use}
      onPointerMove={onPointerMove}
    >
      <div className={styles.frame}>
        <nav
          className={styles.aside}
          aria-label="Workspace"
          data-collapsed={narrow ? '' : undefined}
        >
          <div className={styles.column}>
            <div className={styles.head}>
              <button
                ref={trigger}
                type="button"
                className={styles.workspace}
                data-part="workspace"
                aria-haspopup="menu"
                aria-expanded={menuOpen}
                aria-controls={menuOpen ? `${id}-menu` : undefined}
                inert={narrow}
                onClick={() => (menuOpen ? setMenuOpen(false) : openMenu(true))}
                onKeyDown={onTriggerKey}
              >
                <span className={styles.logo}>
                  <PopsicleIcon />
                </span>
                <span className={`${styles.copy} ${styles.name}`}>{workspace}</span>
                <span className={`${styles.copy} ${styles.caret}`}>
                  <ChevronIcon />
                </span>
              </button>
              <button
                ref={collapseButton}
                type="button"
                className={styles.collapse}
                data-part="collapse"
                aria-label="Collapse sidebar"
                inert={narrow}
                onClick={() => toggleNarrow(true)}
              >
                <SidebarIcon />
              </button>
              <button
                ref={expandButton}
                type="button"
                className={styles.expand}
                data-part="expand"
                aria-label="Expand sidebar"
                inert={!narrow}
                onClick={() => toggleNarrow(false)}
              >
                <SidebarIcon flip />
              </button>
            </div>

            <div ref={nav.attach} className={styles.group} data-glide="nav">
              <span
                data-glide-mark
                data-part="highlight"
                className={styles.mark}
                aria-hidden="true"
              />
              {items.map((item) => (
                <button
                  key={item.id}
                  type="button"
                  className={styles.row}
                  data-glide-item
                  data-part="item"
                  data-id={item.id}
                  data-active={picked === item.id ? '' : undefined}
                  aria-current={picked === item.id ? 'page' : undefined}
                  title={narrow ? item.label : undefined}
                  onClick={() => select(item.id)}
                >
                  <span className={styles.icon}>{drawIcon(item.icon)}</span>
                  <span className={`${styles.copy} ${styles.label} ${styles.labelAfterIcon}`}>
                    {item.label}
                  </span>
                  {item.badge && (
                    <span className={`${styles.copy} ${styles.badge}`} data-part="badge">
                      {item.badge}
                    </span>
                  )}
                </button>
              ))}
            </div>

            <div className={styles.scroll} inert={narrow}>
              <div
                className={`${styles.copy} ${styles.chatsHead}`}
                data-search={searching ? '' : undefined}
              >
                <button
                  type="button"
                  className={styles.chatsToggle}
                  data-part="chats"
                  aria-expanded={!folded}
                  aria-controls={`${id}-chats`}
                  inert={searching}
                  onClick={() => setChatsOpen((open) => !open)}
                >
                  <span className={styles.chevron} data-folded={folded ? '' : undefined}>
                    <ChevronIcon />
                  </span>
                  <span>Chats</span>
                </button>
                <button
                  ref={searchButton}
                  type="button"
                  className={styles.searchButton}
                  data-part="search"
                  aria-label="Search chats"
                  aria-expanded={searching}
                  inert={searching}
                  onClick={() => openSearch(true)}
                >
                  <SearchIcon size={16} />
                </button>
                <div className={styles.searchField} data-part="search-field" inert={!searching}>
                  <span className={styles.searchGlyph}>
                    <SearchIcon size={15} />
                  </span>
                  <input
                    ref={input}
                    className={styles.input}
                    type="search"
                    value={query}
                    placeholder="Search chats"
                    aria-label="Search chat history"
                    aria-controls={`${id}-chats`}
                    autoComplete="off"
                    spellCheck={false}
                    onChange={(event) => setQuery(event.target.value)}
                    onKeyDown={onSearchKey}
                  />
                  <button
                    type="button"
                    className={styles.searchClose}
                    data-part="search-close"
                    aria-label="Close chat search"
                    onClick={() => closeSearch(true)}
                  >
                    <CrossIcon />
                  </button>
                </div>
              </div>

              <div
                id={`${id}-chats`}
                className={styles.fold}
                data-folded={folded ? '' : undefined}
                inert={folded}
              >
                <div className={styles.foldInner}>
                  <div ref={list.attach} className={styles.group} data-glide="chats">
                    <span
                      data-glide-mark
                      data-part="highlight"
                      className={styles.mark}
                      aria-hidden="true"
                    />
                    {shown.map((chat) => (
                      <button
                        key={chat.id}
                        type="button"
                        className={styles.row}
                        data-glide-item
                        data-part="chat"
                        data-id={chat.id}
                        data-active={picked === chat.id ? '' : undefined}
                        aria-current={picked === chat.id ? 'page' : undefined}
                        title={chat.title}
                        onClick={() => select(chat.id)}
                      >
                        <span className={`${styles.copy} ${styles.label}`}>{chat.title}</span>
                      </button>
                    ))}
                    {shown.length === 0 && (
                      <p className={`${styles.copy} ${styles.empty}`} data-part="empty">
                        No chats found
                      </p>
                    )}
                  </div>
                </div>
              </div>
            </div>

            <div className={`${styles.copy} ${styles.foot}`} inert={narrow}>
              <button
                type="button"
                className={styles.upgrade}
                data-part="upgrade"
                onClick={onUpgrade}
              >
                {upgradeLabel}
              </button>
            </div>
          </div>
        </nav>
      </div>

      {menuOpen && (
        <div
          ref={menu}
          id={`${id}-menu`}
          role="menu"
          aria-label={`${workspace} workspace`}
          className={styles.menu}
          data-part="menu"
          style={{ left: menuAt.left, top: menuAt.top }}
          onKeyDown={onMenuKey}
        >
          <div ref={menuGlide.attach} className={styles.menuGroup} data-glide="menu">
            <span data-glide-mark className={styles.menuMark} aria-hidden="true" />
            <button
              type="button"
              role="menuitemradio"
              aria-checked="true"
              tabIndex={-1}
              className={`${styles.menuItem} ${styles.menuCurrent}`}
              data-glide-item
              onClick={() => closeMenu(true)}
            >
              <span className={styles.avatar} aria-hidden="true">
                {workspace.trim().charAt(0).toUpperCase()}
              </span>
              <span className={styles.menuName}>{workspace}</span>
              <span className={styles.check}>
                <CheckIcon />
              </span>
            </button>
            {MENU.map((entry) => (
              <div key={entry.id} className={styles.menuEntry} role="none">
                {(entry.rule || entry === MENU[0]) && (
                  <div className={styles.rule} role="separator" />
                )}
                <button
                  type="button"
                  role="menuitem"
                  tabIndex={-1}
                  className={styles.menuItem}
                  data-glide-item
                  onClick={() => {
                    closeMenu(true);
                    onMenuAction?.(entry.id);
                  }}
                >
                  <span className={styles.menuIcon}>{entry.icon}</span>
                  <span className={styles.menuLabel}>{entry.label}</span>
                </button>
              </div>
            ))}
          </div>
        </div>
      )}

      <p className={styles.hidden} aria-live="polite">
        {needle ? (shown.length ? `${shown.length} chats found` : 'No chats found') : ''}
      </p>
      <DemoPointer ref={pointer} />
    </div>
  );
}

/* ----------------------------------------------------------------------------------------- */
/* Icons: 24-unit drawings, stroked at 2 units, drawn at 15 to 18 px.                          */
/* ----------------------------------------------------------------------------------------- */

function drawIcon(icon: SidebarItem['icon']) {
  if (icon === 'new-chat') return <NewChatIcon />;
  if (icon === 'home') return <HomeIcon />;
  if (icon === 'invite') return <InviteIcon size={18} />;
  return icon;
}

function Glyph({ size = 18, children }: { size?: number; children: ReactNode }) {
  return (
    <svg
      width={size}
      height={size}
      // Inline, so no page-wide svg rule can resize it.
      style={{ width: size, height: size, flexShrink: 0 }}
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth="2"
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
    >
      {children}
    </svg>
  );
}

function PopsicleIcon() {
  return (
    <Glyph>
      <path d="M6.5 7.6a5.5 5.5 0 0 1 11 0V15a1.6 1.6 0 0 1-1.6 1.6H8.1A1.6 1.6 0 0 1 6.5 15z" />
      <path d="M10.2 16.6v3.2a1.8 1.8 0 0 0 3.6 0v-3.2" />
      <path d="M6.6 12.4c1.2-1.2 2.4-1.7 3.7-1.5 1.7.3 2.6 1.3 4.4.9 1.1-.3 2-1 2.8-1.8" />
    </Glyph>
  );
}

function NewChatIcon() {
  return (
    <Glyph>
      <path d="M11 4.2H7.6a3.4 3.4 0 0 0-3.4 3.4v8.8a3.4 3.4 0 0 0 3.4 3.4h8.8a3.4 3.4 0 0 0 3.4-3.4V13" />
      <path d="M9.2 14.8v-2.4l7.9-7.9a1.9 1.9 0 0 1 2.7 2.7l-7.9 7.6z" />
    </Glyph>
  );
}

function HomeIcon() {
  return (
    <Glyph>
      <path d="M4.2 10.1c0-.8.4-1.6 1-2.1l5.2-4.3a2.5 2.5 0 0 1 3.2 0L18.8 8c.6.5 1 1.3 1 2.1v6.5a3.2 3.2 0 0 1-3.2 3.2H7.4a3.2 3.2 0 0 1-3.2-3.2z" />
    </Glyph>
  );
}

function InviteIcon({ size }: { size: number }) {
  return (
    <Glyph size={size}>
      <circle cx="12" cy="7.8" r="4.1" />
      <path d="M11 19.8H6.9c-1.1 0-2-.9-1.8-2 .6-3.2 3-5.8 6.9-5.8" />
      <path d="M18 14.2v5.6M15.2 17h5.6" />
    </Glyph>
  );
}

function ChevronIcon() {
  return (
    <Glyph size={16}>
      <path d="M8.2 10.2l3.8 3.6 3.8-3.6" />
    </Glyph>
  );
}

function SidebarIcon({ flip }: { flip?: boolean }) {
  return (
    <Glyph>
      <rect x="4.1" y="4.1" width="15.8" height="15.8" rx="2.4" />
      <path d="M9.1 4.1v15.8" />
      <path d={flip ? 'M13.6 10l2 2-2 2' : 'M15.6 10l-2 2 2 2'} />
    </Glyph>
  );
}

function SearchIcon({ size }: { size: number }) {
  return (
    <Glyph size={size}>
      <circle cx="11" cy="11" r="6.6" />
      <path d="M19.8 19.8l-3.9-3.9" />
    </Glyph>
  );
}

function CrossIcon() {
  return (
    <Glyph size={16}>
      <path d="M8.2 8.2l7.6 7.6M15.8 8.2l-7.6 7.6" />
    </Glyph>
  );
}

function PlusIcon() {
  return (
    <Glyph size={16}>
      <path d="M12 5.5v13M5.5 12h13" />
    </Glyph>
  );
}

function SettingsIcon() {
  return (
    <Glyph size={16}>
      <path d="M10.6 3.6a2.8 2.8 0 0 1 2.8 0l5.3 3.1c.9.5 1.4 1.4 1.4 2.4v5.8c0 1-.5 1.9-1.4 2.4l-5.3 3.1a2.8 2.8 0 0 1-2.8 0l-5.3-3.1c-.9-.5-1.4-1.4-1.4-2.4V9.1c0-1 .5-1.9 1.4-2.4z" />
      <circle cx="12" cy="12" r="2.8" />
    </Glyph>
  );
}

function SignOutIcon() {
  return (
    <Glyph size={16}>
      <path d="M10.5 4.2H7.4a3.2 3.2 0 0 0-3.2 3.2v9.2a3.2 3.2 0 0 0 3.2 3.2h3.1" />
      <path d="M15 8.2l3.8 3.8-3.8 3.8M18.6 12H9.6" />
    </Glyph>
  );
}

function CheckIcon() {
  return (
    <Glyph size={18}>
      <path d="M6.5 12.6l3.6 3.6 7.4-8.2" />
    </Glyph>
  );
}
```

### sidebar-nav/sidebar-nav.module.css

```css
/* Sidebar nav (sidebar-nav.tsx): a quiet stage with the rail standing in its middle, 224 px wide
   and 600 px tall, on the stage's own colour. At night the stage is near-black and the fills a
   shade lighter; by day the stage is a pale grey and the fills a shade darker.

   Colours are the tokens below, light-dark(light, dark) where the two differ: 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).

   Memory. What moves is the rail's width, the rows' widths, a few opacities, transforms and
   colours, and the highlights' top and height; nothing runs while the sidebar is still. */

.root {
  /* The words: the stronger ones, the labels and icons, the quiet ones (counts, headings). */
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* The stage, the menu, the search field, and the fill under a hovered or picked row. */
  --canvas: oklch(96.1% 0.002 247.84);
  --surface: oklch(100% 0 0);
  --field: oklch(96.1% 0.001 286.375);
  --fill: oklch(93.3% 0.003 247.86);
  /* Edges: the stage's and the foot's rule; the Upgrade button under the pointer. */
  --line: oklch(94.6% 0.003 264.542);
  --line-strong: oklch(91.2% 0.005 258.326);
  /* The menu's lift: a ring and a long soft shadow by day, a pale ring and a deep one at night. */
  --menu-ring: var(--line);
  --menu-1: #0000000d;
  --menu-2: #0000000a;
  --menu-3: #00000008;
  --menu-4: #00000005;
  --menu-night: transparent;
  /* The same on both schemes: the focus ring, and the demo's pointer. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  /* The rail's ease, long and soft; the highlights' and the menu's, steeper; the controls'. */
  --ease-rail: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  min-height: 624px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: inset 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);
  font-size: 14px;
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --field: light-dark(oklch(96.1% 0.001 286.375), oklch(29.3% 0.006 271.223));
    --fill: light-dark(oklch(93.3% 0.003 247.86), oklch(31.8% 0.007 274.747));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --line-strong: light-dark(oklch(91.2% 0.005 258.326), oklch(35.6% 0.007 264.474));
    --menu-ring: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.15));
    --menu-1: light-dark(#0000000d, transparent);
    --menu-2: light-dark(#0000000a, transparent);
    --menu-3: light-dark(#00000008, transparent);
    --menu-4: light-dark(#00000005, transparent);
    --menu-night: light-dark(transparent, oklch(0% 0 0 / 0.34));
    --focus: light-dark(#387bff, #6ea0ff);
  }
}

/* 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;
}

:where(.root) button,
:where(.root) input {
  padding: 0;
  margin: 0;
  border: 0;
  background: none;
  font: inherit;
  letter-spacing: inherit;
  color: inherit;
}
:where(.root) button {
  cursor: pointer;
}
:where(.root) p {
  margin: 0;
}
.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: -2px;
}

/* ------------------------------------------------------------------------------------------ */
/* The rail                                                                                     */
/* ------------------------------------------------------------------------------------------ */

/* Up to 480 px across, the rail in its middle: narrowing, it stays centred. */
.frame {
  display: flex;
  justify-content: center;
  width: 100%;
  max-width: 480px;
}

.aside {
  position: relative;
  display: flex;
  flex-shrink: 0;
  width: 224px;
  height: 600px;
  overflow: hidden;
  transition: width 280ms var(--ease-rail);
}
.aside[data-collapsed] {
  width: 52px;
}

/* The content keeps its 224 px: the rail's edge cuts it as it narrows. */
.column {
  display: flex;
  flex-direction: column;
  flex-shrink: 0;
  width: 224px;
  min-height: 0;
}

/* Every label: it fades and drifts 8 px left as the rail narrows. */
.copy {
  transition:
    opacity 180ms ease-out,
    transform 180ms var(--ease-rail);
}
.aside[data-collapsed] .copy {
  opacity: 0;
  transform: translateX(-8px);
}

/* The head: the workspace switcher, the collapse button, and the expand button in its place. */
.head {
  position: relative;
  flex-shrink: 0;
  height: 40px;
  margin-bottom: 10px;
}

.workspace {
  position: absolute;
  top: 4px;
  left: 8px;
  display: flex;
  align-items: center;
  width: 164px;
  height: 32px;
  padding: 0 8px;
  border-radius: 8px;
  text-align: left;
  transition:
    background-color 100ms var(--ease),
    transform 100ms var(--ease);
}
.workspace[data-hover] {
  background-color: var(--fill);
}
.workspace:active {
  transform: scale(0.99);
}
.logo {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  color: var(--ink);
  transition: opacity 180ms ease-out;
}
.aside[data-collapsed] .logo {
  opacity: 0;
}
.name {
  flex: 1;
  min-width: 0;
  margin-left: 6px;
  overflow: hidden;
  color: var(--ink-2);
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.caret {
  display: flex;
  flex-shrink: 0;
  margin-left: 4px;
  color: var(--ink-3);
}

.collapse,
.expand {
  position: absolute;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    opacity 180ms ease-out,
    background-color 150ms var(--ease),
    color 150ms var(--ease);
}
.collapse {
  top: 4px;
  right: 8px;
  width: 32px;
  height: 32px;
}
.expand {
  top: 2px;
  left: 8px;
  width: 36px;
  height: 36px;
  opacity: 0;
}
.aside[data-collapsed] .collapse {
  opacity: 0;
}
.aside[data-collapsed] .expand {
  opacity: 1;
}
.collapse[data-hover],
.expand[data-hover] {
  background-color: var(--fill);
  color: var(--ink);
}

/* ------------------------------------------------------------------------------------------ */
/* The lists and their gliding highlight                                                        */
/* ------------------------------------------------------------------------------------------ */

.group {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

/* The highlight: placed by the script (top, height, opacity), glided by these transitions. */
.mark {
  position: absolute;
  top: 0;
  left: 8px;
  width: calc(100% - 16px);
  height: 0;
  border-radius: 7px;
  background: var(--fill);
  opacity: 0;
  pointer-events: none;
  transition:
    top 220ms var(--ease-out),
    height 220ms var(--ease-out),
    opacity 150ms ease,
    width 180ms var(--ease-rail);
}

.row {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  width: calc(100% - 16px);
  height: 32px;
  margin: 0 8px;
  padding: 0 8px;
  border-radius: 8px;
  text-align: left;
  transition:
    width 180ms var(--ease-rail),
    background-color 180ms var(--ease-rail),
    color 180ms var(--ease-rail),
    transform 150ms var(--ease);
}
.aside[data-collapsed] .row,
.aside[data-collapsed] .mark {
  width: 36px;
}
.row:active {
  transform: scale(0.98);
}
/* The picked row keeps the fill, which gives way to the highlight while it is in its list. */
.row[data-active] {
  background-color: var(--fill);
}
.group[data-gliding] .row[data-active] {
  background-color: transparent;
}
.row[data-active] .label,
.row[data-active] .icon {
  color: var(--ink);
}

.icon {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  color: var(--ink-2);
}
.label {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  color: var(--ink-2);
  font-weight: 500;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.labelAfterIcon {
  margin-left: 6px;
}
.badge {
  flex-shrink: 0;
  margin-right: 8px;
  color: var(--ink-3);
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  font-variant-numeric: tabular-nums;
}

/* ------------------------------------------------------------------------------------------ */
/* The Chats group                                                                              */
/* ------------------------------------------------------------------------------------------ */

.scroll {
  flex: 1;
  min-height: 0;
  margin-top: 12px;
  overflow-y: auto;
  scrollbar-width: none;
  transition: opacity 180ms ease-out;
}
/* Narrowed, the chats have no icons to keep: the whole group fades with the labels, the picked
   chat's fill with it. */
.aside[data-collapsed] .scroll {
  opacity: 0;
}
.scroll::-webkit-scrollbar {
  display: none;
}

.chatsHead {
  position: relative;
  height: 32px;
  margin: 0 8px 4px;
}

/* The heading folds the group. With the search open it fades and slips 4 px left. */
.chatsToggle {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 0 8px;
  border-radius: 8px;
  color: var(--ink-3);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 18.75px;
  text-align: left;
  transition:
    opacity 180ms var(--ease-rail),
    transform 180ms var(--ease-rail),
    color 150ms var(--ease);
}
.chatsToggle[data-hover] {
  color: var(--ink-2);
}
.chatsHead[data-search] .chatsToggle {
  opacity: 0;
  transform: translateX(-4px);
}
.chevron {
  display: flex;
  transition: transform 220ms var(--ease-out);
}
.chevron[data-folded] {
  transform: rotate(-90deg);
}

.searchButton {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    opacity 180ms var(--ease),
    background-color 180ms var(--ease),
    color 180ms var(--ease),
    transform 180ms var(--ease);
}
.searchButton[data-hover] {
  background-color: var(--fill);
  color: var(--ink);
}
.searchButton:active {
  transform: scale(0.96);
}
.chatsHead[data-search] .searchButton {
  opacity: 0;
}

/* The field grows out of the button, from its 28 px to the whole row. */
.searchField {
  position: absolute;
  top: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  width: 28px;
  height: 32px;
  overflow: hidden;
  border-radius: 8px;
  background: var(--field);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-3);
  opacity: 0;
  pointer-events: none;
  transition:
    width 180ms var(--ease-rail),
    opacity 180ms var(--ease-rail);
}
.chatsHead[data-search] .searchField {
  width: 100%;
  opacity: 1;
  pointer-events: auto;
}
.searchField:focus-within {
  color: var(--ink-2);
}
.searchGlyph {
  display: flex;
  flex-shrink: 0;
  margin-left: 8px;
}
.input {
  flex: 1;
  min-width: 0;
  margin-left: 6px;
  color: var(--ink);
  font-size: 13px;
  font-weight: 500;
  line-height: 19.5px;
  outline: none;
  appearance: none;
}
.input::placeholder {
  color: var(--ink-3);
  opacity: 1;
}
.input::-webkit-search-cancel-button {
  display: none;
}
.root .input:focus-visible {
  outline: none;
}
.searchClose {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 8px;
  color: var(--ink-3);
  transition:
    background-color 150ms var(--ease),
    color 150ms var(--ease),
    transform 150ms var(--ease);
}
.searchClose[data-hover] {
  background-color: var(--fill);
  color: var(--ink);
}
.searchClose:active {
  transform: scale(0.96);
}

/* Folding: the list's row of the grid closes to nothing. */
.fold {
  display: grid;
  grid-template-rows: 1fr;
  transition:
    grid-template-rows 220ms var(--ease-out),
    opacity 180ms ease-out;
}
.fold[data-folded] {
  grid-template-rows: 0fr;
  opacity: 0;
}
.foldInner {
  min-height: 0;
  overflow: hidden;
}

.empty {
  margin: 0 8px;
  padding: 8px;
  color: var(--ink-3);
  font-size: 12.5px;
  line-height: 18.75px;
}

/* ------------------------------------------------------------------------------------------ */
/* The foot                                                                                     */
/* ------------------------------------------------------------------------------------------ */

.foot {
  flex-shrink: 0;
  width: 208px;
  margin: 12px 8px 0;
  padding-top: 12px;
  border-top: 1px solid var(--line);
}
.upgrade {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 32px;
  border-radius: 8px;
  background: var(--fill);
  color: var(--ink);
  font-size: 12.5px;
  font-weight: 500;
  line-height: 18.75px;
  transition:
    background-color 150ms var(--ease),
    transform 150ms var(--ease);
}
.upgrade:active {
  transform: scale(0.98);
}

/* ------------------------------------------------------------------------------------------ */
/* The workspace menu                                                                           */
/* ------------------------------------------------------------------------------------------ */

.menu {
  position: absolute;
  z-index: 5;
  width: 256px;
  padding: 6px;
  border-radius: 14px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--menu-ring),
    0 8px 28px var(--menu-night),
    0 25px 50px var(--menu-1),
    0 12px 24px var(--menu-2),
    0 6px 12px var(--menu-3),
    0 3px 6px var(--menu-4),
    0 1.5px 3px var(--menu-4);
  transform-origin: 0 0;
  animation: pop 180ms var(--ease-out) both;
}
@keyframes pop {
  from {
    opacity: 0;
    transform: scale(0.95);
  }
}
.menuGroup {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.menuEntry {
  display: flex;
  flex-direction: column;
}
.menuMark {
  position: absolute;
  top: 0;
  right: 0;
  left: 0;
  height: 0;
  border-radius: 8px;
  background: var(--fill);
  opacity: 0;
  pointer-events: none;
  transition:
    top 220ms var(--ease-out),
    height 220ms var(--ease-out),
    opacity 150ms ease;
}
.menuItem {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  height: 36px;
  padding: 0 8px;
  border-radius: 8px;
  color: var(--ink);
  text-align: left;
}
.root .menuItem:focus-visible {
  outline: none;
}
.menuCurrent {
  height: 40px;
}
.avatar {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 7px;
  background: var(--ink);
  color: var(--surface);
  font-size: 11px;
  font-weight: 600;
  line-height: 16.5px;
}
.menuName,
.menuLabel {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  font-size: 13.5px;
  line-height: 20.25px;
  white-space: nowrap;
  text-overflow: ellipsis;
}
.menuName {
  font-weight: 500;
}
.check {
  display: flex;
  flex-shrink: 0;
}
.menuIcon {
  display: flex;
  flex-shrink: 0;
  align-items: center;
  justify-content: center;
  width: 20px;
  height: 20px;
  color: var(--ink-2);
}
.rule {
  height: 1px;
  margin: 4px 0;
  background: var(--line);
}
.menuEntry .rule {
  margin-bottom: 5px;
}

/* ------------------------------------------------------------------------------------------ */

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

/* Hovers: the reader's pointer, where it can hover (the demo's sets [data-hover]). */
@media (hover: hover) {
  .workspace:hover,
  .collapse:hover,
  .expand:hover,
  .searchButton:hover,
  .searchClose:hover {
    background-color: var(--fill);
  }
  .collapse:hover,
  .expand:hover,
  .searchButton:hover,
  .searchClose:hover {
    color: var(--ink);
  }
  .chatsToggle:hover {
    color: var(--ink-2);
  }
  .upgrade:hover {
    background-color: var(--line-strong);
  }
}
.upgrade[data-hover] {
  background-color: var(--line-strong);
}

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

### sidebar-nav/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move 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.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

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

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?: Point) => 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 {}

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.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: HTMLElement,
  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.86, y: stage.offsetHeight * 0.96 };

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

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    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(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { 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';
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (sidebar-nav.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### sidebar-nav/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 };
}
```