Sidebar and canvas Persistent navigation across many dashboards without stealing the canvas. A sidebar earns its 240px by showing where you are in a set of fifty, and stops earning it the moment the set is six. npx shadcn@latest add scroll-area button npm i lucide-react Tokens this needs: --card, --foreground, --muted, --muted-foreground, --border, --chart-1 ──────────────────────────────────────────────────────────────────────── // Shell.tsx ──────────────────────────────────────────────────────────────────────── import { useState, type ReactNode } from "react"; import { Menu, PanelLeftClose } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { cn } from "@/lib/utils"; export type NavItem = { id: string; label: string }; export type NavSection = { label: string; items: NavItem[] }; /** * A rail and a canvas. The rail is narrow, always present, and scrolls on * its own rather than with the page. It highlights the current item, which * is the whole reason it earns its width: "where am I" answered continuously, * for someone who arrived from an alert link and has no idea what else exists. * * Two things are decided here rather than discovered later. Collapse reclaims * the width on a dense page. Below the breakpoint the rail hides, and the menu * button that replaces it opens the same list, so the narrow layout still has * a route everywhere. */ export function Shell({ workspace, sections, current, onNavigate, children, railWidth = 240 }: { workspace: string; sections: NavSection[]; /** The id of the item the canvas is showing. Required: a rail without a * current position is a list of links, and a breadcrumb does that. */ current: string; onNavigate: (id: string) => void; children: ReactNode; railWidth?: number; }) { const [collapsed, setCollapsed] = useState(false); const [open, setOpen] = useState(false); // the narrow layout's overlay const rail = ( ); return (
{/* Wide: the rail is a column. It never scrolls with the canvas. */} {!collapsed && ( )}
{collapsed && ( )} {sections.flatMap((s) => s.items).find((it) => it.id === current)?.label}
{children} {/* Narrow: the same list, as an overlay the menu button opens. */} {open && ( )}
); } ──────────────────────────────────────────────────────────────────────── // demo.tsx ──────────────────────────────────────────────────────────────────────── import { useState } from "react"; import { Shell, type NavSection } from "./Shell"; /** * Two sections in the rail, checkout as the current position, and three * panels of requests per second on the canvas. Narrow the window and the * rail becomes a menu button that opens the same list. */ const SECTIONS: NavSection[] = [ { label: "Services", items: [ { id: "gateway", label: "API gateway" }, { id: "checkout", label: "Checkout" }, { id: "search", label: "Search" }, { id: "cart", label: "Cart" }, { id: "users", label: "Users" }, { id: "feed", label: "Feed" }, ] }, { label: "Infrastructure", items: [ { id: "k8s", label: "Kubernetes" }, { id: "postgres", label: "Postgres" }, { id: "redis", label: "Redis" }, { id: "cdn", label: "CDN" }, ] }, ]; const PANELS = [ { title: "Requests per second", values: [220, 236, 226, 268, 258, 292, 312] }, { title: "p95 latency, ms", values: [180, 172, 190, 168, 175, 162, 158] }, { title: "Error rate", values: [1.2, 1.1, 1.4, 0.9, 1.0, 0.8, 0.7] }, ]; const Spark = ({ values }: { values: number[] }) => { const max = Math.max(...values), min = Math.min(...values); const pts = values.map((v, i) => `${(i / (values.length - 1)) * 120},${28 - ((v - min) / (max - min || 1)) * 24}`); return ( ); }; export default function Demo() { const [current, setCurrent] = useState("checkout"); return (
{PANELS.map((p, i) => (

{p.title}

{p.values.at(-1)}

))}
); }