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 && (
)}