Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Sidebar nav

New

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.

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

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

When to use

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

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

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

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

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

Usage

app-sidebar.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')}    />  );}

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

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

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

  • 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.
PropTypeDefaultDescription
workspacestring'Creamery Ops'The workspace’s name, on the switcher and atop its menu.
itemsSidebarItem[]—The actions under the switcher: an id, a label, an icon (new-chat, home, invite, or your own node) and an optional badge.
chatsSidebarChat[]—The chats in the Chats group, each an id and a title.
activestring | null—The picked row’s id; with it, the pick is controlled (pair it with onSelect).
defaultActivestring | nullnullThe row picked first, when uncontrolled.
onSelect(id: string) => void—Called with the id of the action or chat pressed.
collapsedboolean—Whether the rail is narrowed to its icons; with it, collapsing is controlled.
defaultCollapsedbooleanfalseWhether it starts narrowed, when uncontrolled.
onCollapsedChange(collapsed: boolean) => void—Called with the new state when the collapse or expand button is pressed.
defaultStateSidebarNavState—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.
upgradeLabelstring'Upgrade'The foot button’s label.
onUpgrade() => void—Called when the foot button is pressed.
autoplaybooleantrueWhile 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.
classNamestring—Extra class for the root.

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

sidebar-nav.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.
sidebar-nav.module.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);
demo-pointer.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>;
use-autoplay.ts
'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) {  const ref = useRef<T>(null);  const [visible, setVisible] = useState(false);  const [reduced, setReduced] = useState(true);  const [taken, setTaken] = useState(false);  const timer = useRef(0);  useEffect(() => {    const element = ref.current;    if (!element) return;    let onScreen = false;    const update = () => setVisible(onScreen && !document.hidden);    const observer = new IntersectionObserver(      ([entry]) => {        onScreen = entry.isIntersecting;
  • 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.