Sidebar

Persistent navigation for products with more destinations than a Navbar can hold.

Navigation Sidebar.jsx Sidebar.d.ts Sidebar.md

Playground

Change a prop and watch it. The code below follows along.

Install and import

Sidebar is in @dovetail-ds/react. Install it with React, if you haven't:

npm install @dovetail-ds/react react react-dom
import { Sidebar } from "@dovetail-ds/react";

Import @dovetail-ds/react/styles.css once at your app's root, then your theme after it. Setup covers the stylesheets, fonts and dark mode.

Guidelines

Persistent navigation for products with more destinations than a Navbar can hold.

Rules

  • Group with sections once you pass roughly seven items. The section eyebrow is the one place uppercase is allowed.
  • Icons are optional but all-or-nothing within a section. A mixed column reads as broken.
  • The active item carries aria-current="page" plus a filled surface. Do not rely on the surface alone.
  • Counts go in trailing as a Badge. Do not append them to the label string; they break truncation.
  • Below roughly 900px, move the sidebar into a Drawer rather than shrinking it.

Tradeoffs

A sidebar costs 240px of every screen forever in exchange for one-click access. On content-heavy pages, a collapsible rail buys the width back at the cost of a click.

Props

The typed contract, from Sidebar.d.ts.

import * as React from "react";

export interface SidebarItem {
  id: string;
  label: React.ReactNode;
  href?: string;
  /** Lucide icon before the label. */
  icon?: React.ReactNode;
  /** Trailing slot, usually a Badge count. */
  trailing?: React.ReactNode;
}

export interface SidebarSection {
  /** Uppercase eyebrow above the group. Omit for an ungrouped list. */
  title?: string;
  items: SidebarItem[];
}

/** Vertical navigation rail for product shells. */
export interface SidebarProps extends React.HTMLAttributes<HTMLElement> {
  sections: SidebarSection[];
  /** Id of the active item. */
  current?: string;
  onNavigate?: (id: string) => void;
  /** Slot above the sections — workspace switcher, logo. */
  header?: React.ReactNode;
  /** Slot pinned to the bottom — account, help. */
  footer?: React.ReactNode;
  /** @default "Sections" */
  label?: string;
  /** @default 240 */
  width?: number | string;
}

export declare function Sidebar(props: SidebarProps): React.JSX.Element;

Tokens it reads

Every custom property Sidebar.jsx resolves, read from the source: 18 semantic, 4 primitive, 2 undeclared. Component tier first, because that is the one to reach for.

  • semantic Shared roles. Override one and everything using that role moves with it.
  • primitive Raw values. A component reading one directly is a deliberate exception.
  • none Referenced by the source but declared by no tier, so it resolves to nothing.
TokenTierDeclared as
--dt-border-subtlesemanticvar(--dt-color-neutral-100)
--dt-border-width-defaultsemanticvar(--dt-dim-hair)
--dt-radius-controlsemanticvar(--dt-radius-raw-8)
--dt-space-inline-smsemanticvar(--dt-dim-3)
--dt-space-inset-mdsemanticvar(--dt-dim-4)
--dt-space-inset-smsemanticvar(--dt-dim-3)
--dt-space-inset-xssemanticvar(--dt-dim-2)
--dt-space-stack-2xssemanticvar(--dt-dim-1)
--dt-space-stack-mdsemanticvar(--dt-dim-4)
--dt-surface-selectedsemanticvar(--dt-color-neutral-100)
--dt-surface-subtlesemanticvar(--dt-color-neutral-050)
--dt-text-eyebrow-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-familysemanticvar(--dt-font-family-secondary)
--dt-text-label-md-sizesemanticvar(--dt-font-size-sm)
--dt-text-label-sm-sizesemanticvar(--dt-font-size-xs)
--dt-text-on-selectedsemanticvar(--dt-color-neutral-900)
--dt-text-secondarysemanticvar(--dt-color-neutral-600)
--dt-text-tertiarysemanticvar(--dt-color-neutral-500)
--dt-font-family-monoprimitive"Geist Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace
--dt-font-weight-mediumprimitive500
--dt-font-weight-regularprimitive400
--dt-tracking-wideprimitive0.02em
--dt-motion-duration-fastnonenot declared
--dt-motion-easing-standardnonenot declared

Source

React only, no dependencies, every value a token.

import React from "react";

export function Sidebar({ sections = [], current, onNavigate, header, footer, label = "Sections", width = 240, style, ...rest }) {
  return (
    <nav aria-label={label} style={{
      width, flex: "none", display: "flex", flexDirection: "column",
      gap: "var(--dt-space-stack-md)", padding: "var(--dt-space-inset-md)",
      background: "var(--dt-surface-subtle)",
      borderRight: "var(--dt-border-width-default) solid var(--dt-border-subtle)",
      boxSizing: "border-box", ...style,
    }} {...rest}>
      {header}
      {sections.map((s, si) => (
        <div key={s.title || si} style={{ display: "flex", flexDirection: "column", gap: "var(--dt-space-stack-2xs)" }}>
          {s.title && (
            <span style={{
              fontFamily: "var(--dt-text-eyebrow-family, var(--dt-font-family-mono))",
              fontSize: "var(--dt-text-label-sm-size)", textTransform: "uppercase",
              letterSpacing: "var(--dt-tracking-wide, 0.05em)", color: "var(--dt-text-tertiary)",
              padding: "0 var(--dt-space-inset-xs)",
            }}>{s.title}</span>
          )}
          <ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 2 }}>
            {s.items.map(it => {
              const on = it.id === current;
              return (
                <li key={it.id}>
                  <a
                    href={it.href || "#"}
                    aria-current={on ? "page" : undefined}
                    onClick={e => { if (onNavigate) { e.preventDefault(); onNavigate(it.id); } }}
                    style={{
                      display: "flex", alignItems: "center", gap: "var(--dt-space-inline-sm)",
                      padding: "var(--dt-space-inset-xs) var(--dt-space-inset-sm)",
                      borderRadius: "var(--dt-radius-control)", textDecoration: "none",
                      background: on ? "var(--dt-surface-selected)" : "transparent",
                      color: on ? "var(--dt-text-on-selected)" : "var(--dt-text-secondary)",
                      fontFamily: "var(--dt-text-label-md-family)", fontSize: "var(--dt-text-label-md-size)",
                      fontWeight: on ? "var(--dt-font-weight-medium)" : "var(--dt-font-weight-regular)",
                      transition: "background var(--dt-motion-duration-fast) var(--dt-motion-easing-standard)",
                    }}
                  >
                    {it.icon && <span style={{ flex: "none", display: "flex" }}>{it.icon}</span>}
                    <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{it.label}</span>
                    {it.trailing}
                  </a>
                </li>
              );
            })}
          </ul>
        </div>
      ))}
      {footer && <div style={{ marginTop: "auto" }}>{footer}</div>}
    </nav>
  );
}

In context

Sidebar also appears on the navigation reference cards, alongside the rest of its family.