Skip to content

Sidebar Tree

Docs / settings sidebar with a filter box and a nested collapsible tree auto-expanded to the active page (highlighted in indigo); stacks above content on mobile.

minimal/layout/sidebar-tree
Open ↗

Source

"use client";

import { useId, useState, type ReactNode } from "react";
import { ChevronRight, Search } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SidebarTreeNode {
  label: string;
  href?: string;
  children?: SidebarTreeNode[];
}

export interface SidebarTreeProps {
  title?: string;
  tree?: SidebarTreeNode[];
  activeHref?: string;
  children?: ReactNode;
  className?: string;
}

const DEFAULT_TREE: SidebarTreeNode[] = [
  {
    label: "Getting started",
    children: [
      { label: "Quickstart", href: "#quickstart" },
      { label: "Core concepts", href: "#concepts" },
      { label: "Migrate from PagerDuty", href: "#migrate" },
    ],
  },
  {
    label: "Alerting",
    children: [
      { label: "Integrations", href: "#integrations" },
      {
        label: "Routing rules",
        children: [
          { label: "Conditions", href: "#conditions" },
          { label: "Deduplication", href: "#dedup" },
          { label: "Grouping", href: "#grouping" },
        ],
      },
      { label: "Escalation policies", href: "#escalation" },
    ],
  },
  {
    label: "Incidents",
    children: [
      { label: "Declaring", href: "#declaring" },
      { label: "Roles", href: "#roles" },
      { label: "Postmortems", href: "#postmortems" },
    ],
  },
  { label: "API reference", href: "#api" },
  { label: "Changelog", href: "#changelog" },
];

function contains(n: SidebarTreeNode, href: string): boolean {
  return n.href === href || (n.children?.some((c) => contains(c, href)) ?? false);
}

function filter(nodes: SidebarTreeNode[], q: string): SidebarTreeNode[] {
  if (!q) return nodes;
  return nodes.flatMap((n) => {
    const kids = n.children ? filter(n.children, q) : [];
    if (n.label.toLowerCase().includes(q) || kids.length) return [{ ...n, children: n.children ? (kids.length ? kids : n.children) : undefined }];
    return [];
  });
}

function Node({ node, depth, activeHref, forceOpen }: { node: SidebarTreeNode; depth: number; activeHref: string; forceOpen: boolean }) {
  const [open, setOpen] = useState(contains(node, activeHref));
  const id = useId();
  const isOpen = open || forceOpen;
  const pad = { paddingLeft: `${0.5 + depth * 0.75}rem` };

  if (!node.children) {
    const active = node.href === activeHref;
    return (
      <li>
        <a
          aria-current={active ? "page" : undefined}
          href={node.href}
          style={pad}
          className={cn(
            "da-focus da-transition block rounded-da-sm py-1.5 pr-2 text-[13px]",
            active ? "bg-da-accent font-medium text-da-accent-fg" : "text-da-muted-fg hover:bg-da-fg/5 hover:text-da-fg",
          )}
        >
          {node.label}
        </a>
      </li>
    );
  }
  return (
    <li>
      <button
        type="button"
        aria-expanded={isOpen}
        aria-controls={id}
        onClick={() => setOpen((o) => !o)}
        style={pad}
        className={cn(
          "da-focus da-transition flex w-full items-center gap-1 rounded-da-sm py-1.5 pr-2 text-left text-[13px] hover:bg-da-fg/5",
          depth === 0 ? "font-medium text-da-fg" : "text-da-muted-fg hover:text-da-fg",
        )}
      >
        <ChevronRight aria-hidden className={cn("da-transition size-3.5 shrink-0 text-da-muted-fg", isOpen && "rotate-90")} />
        {node.label}
      </button>
      <ul id={id} hidden={!isOpen}>
        {node.children.map((c) => (
          <Node key={c.label} node={c} depth={depth + 1} activeHref={activeHref} forceOpen={forceOpen} />
        ))}
      </ul>
    </li>
  );
}

/** Docs/settings sidebar with a filter box and a nested, collapsible tree (auto-expanded to the active page). */
export function SidebarTree({ title = "Documentation", tree = DEFAULT_TREE, activeHref = "#dedup", children, className }: SidebarTreeProps) {
  const [q, setQ] = useState("");
  const id = useId();
  const shown = filter(tree, q.trim().toLowerCase());

  return (
    <div className={cn("flex min-h-dvh flex-col bg-da-bg text-da-fg md:flex-row", className)}>
      <aside className="border-b border-da-border md:sticky md:top-0 md:h-dvh md:w-64 md:shrink-0 md:overflow-y-auto md:border-r md:border-b-0">
        <div className="p-3">
          <p className="px-2 py-1.5 text-[13px] font-semibold">{title}</p>
          <div className="relative mt-2">
            <Search aria-hidden className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-da-muted-fg" />
            <label htmlFor={id} className="sr-only">
              Filter pages
            </label>
            <input
              id={id}
              value={q}
              onChange={(e) => setQ(e.target.value)}
              placeholder="Filter…"
              className="da-focus h-8 w-full rounded-da-md border border-da-border bg-da-input pr-2 pl-8 text-[13px] placeholder:text-da-muted-fg"
            />
          </div>
        </div>
        <nav aria-label={title} className="max-h-72 overflow-y-auto px-3 pb-4 md:max-h-none">
          <ul className="space-y-0.5">
            {shown.map((n) => (
              <Node key={n.label} node={n} depth={0} activeHref={activeHref} forceOpen={q.length > 0} />
            ))}
          </ul>
          {shown.length === 0 && <p className="px-2 py-4 text-[13px] text-da-muted-fg">No pages match.</p>}
        </nav>
      </aside>
      <main className="min-w-0 flex-1">{children}</main>
    </div>
  );
}

export default SidebarTree;

modules/minimal/layout/sidebar-tree/index.tsx

Props

PropTypeDefaultDescription
titlestring"Documentation"Heading and nav label.
tree{ label, href?, children? }[]—Nested nodes.
activeHrefstring"#dedup"Current page.
childrenReactNode—Page content.
classNamestring—Classes on the root.

Other sidebar variants in Minimal

Sidebar in other art directions

App Sidebar

App shell with a sticky left sidebar: workspace header, grouped nav with yellow active state and counters, usage meter, user footer. Becomes a top bar + slide-over drawer below lg.

BrutalistSidebar

Icon Rail

Compact 80px icon rail: ink brand square, square icon links with yellow active state, red counters and ink tooltips on hover/focus, settings pinned at the bottom. Server-safe, CSS-only tooltips.

BrutalistSidebar

Sidebar Collapsible

App sidebar that collapses from 256px to a 76px icon column with a stepped width animation; ink active item, labels become sr-only when collapsed, collapse toggle pinned at the bottom.

BrutalistSidebar

Sidebar Dual

Two-level navigation: an ink icon rail switches sections, and a paper second panel lists that section's links with counts and a yellow active link. Second panel hides on very small screens.

BrutalistSidebar

App Sidebar

Floating frosted sidebar inset from the viewport edges: gradient-orb brand, pill nav items with an ink active state and gradient badges, workspace group, gradient promo card and user chip. Becomes a floating pill top bar + glass drawer below lg.

GlassSidebar

Sidebar Adaptive

Adaptive navigation: a full glass sidebar with brand, gradient action button, labelled links and a usage meter on desktop; a floating bottom tab bar with a raised gradient “+” on mobile.

GlassSidebar

Sidebar Channels

Chat-app style double sidebar: a column of gradient workspace squircles plus a glass channel panel with collapsible sections, private/live icons, unread counts and LIVE meeting badges; drawer on mobile.

GlassSidebar

Sidebar Floating

Floating glass icon rail detached from the viewport edges: gradient logo orb, gradient active item, unread badges, glass tooltips on hover/focus, settings and avatar at the bottom; a floating bottom bar on mobile.

GlassSidebar

App Sidebar

Typographic app sidebar: hairline right border, logo + site switcher, mono group labels, plain text links with right-aligned mono counts; the active link is ink with a signal dot; drawer on mobile.

Mono CleanSidebar

Sidebar Icons

Narrow icon rail (56px) of square hairline cells; the active cell is filled ink; labels appear as mono tooltips to the right on hover/focus; turns into a bottom bar on mobile.

Mono CleanSidebar

Sidebar Ink

Ink sidebar (stays near-black in dark mode): giant numbered text links “01 Overview”, the active one full white with an arrow, a storage meter as a hairline bar at the bottom; drawer on mobile.

Mono CleanSidebar

Sidebar Tree

CMS page tree: nested pages drawn with hairline guide lines and +/− toggles (aria-expanded), mono “Pages” header with an add button; active page ink with signal dot; drawer on mobile.

Mono CleanSidebar

App Sidebar

Classic enterprise app shell: white bordered sidebar with brand, entity switcher, grouped nav (uppercase group labels, count badges, blue active state), settings and user footer; off-canvas drawer on mobile.

Neo CorporateSidebar

Sidebar Collapsible

Sidebar that collapses from 240px to a 64px icon rail with a toggle button (aria-expanded); collapsed items keep tooltips (title) and sr-only labels. Always a rail on small screens.

Neo CorporateSidebar

Sidebar Dual

Two-level navigation: a narrow icon rail of top-level sections (tooltips, aria-pressed) and a secondary panel listing the selected section’s pages; the panel hides below md.

Neo CorporateSidebar

Sidebar Navy

Dark navy sidebar (fg-colored in light mode, surface in dark mode — dark in both): brand, search field, nav with white-tint active state and badges, and a cash-balance card at the bottom; drawer on mobile.

Neo CorporateSidebar

App Sidebar

Calm app shell: cream sidebar with leaf logo, pill nav items (sage pill for the active page), a small “this year” footprint card with a sprout, and settings at the bottom; drawer on mobile.

Organic SoftSidebar

Sidebar Floating

Floating sidebar: a rounded cream panel inset from the page edges with a soft shadow, italic serif group labels, a sage “New entry” pill and sage-tinted active items; drawer on mobile.

Organic SoftSidebar

Sidebar Garden

Deep sage sidebar (dark sage tint in dark mode) with cream text, a translucent active pill, count bubbles, rolling hills illustrated along the bottom and the user’s name on the hill; drawer on mobile.

Organic SoftSidebar

Sidebar Rail

Slim round icon rail: circular icon links on a sand column, the active one a sage circle with a leaf-shaped marker; labels appear as soft tooltips on hover/focus; becomes a bottom tab bar on mobile.

Organic SoftSidebar

App Sidebar

Friendly app shell: warm sidebar with emoji workspace chip, big periwinkle “New task” pill, search field, nav with peach count bubbles and a colored-dot board list; slide-in drawer on mobile.

Soft FlatSidebar

Sidebar Boards

Board navigator: starred favorites, then collapsible emoji spaces holding emoji boards with count bubbles; active board in a white pill; dashed “New space” button.

Soft FlatSidebar

Sidebar Card

Floating white sidebar card with generous radius on a tinted page: two-dot logo, nav with lavender active pill, butter “tip of the day” card and user row; horizontal nav on mobile.

Soft FlatSidebar

Sidebar Rail

Slim rounded icon rail: round icons whose active item fills with its own pastel color, dark pill tooltips, emoji avatar at the bottom; floating bottom bar on mobile.

Soft FlatSidebar