DGit UI › Blocks
Sidebar layout
Dashboard with a sidebar: drill-in groups and route layers that slide in with a back row, a top bar with a scope switcher and breadcrumbs, a drawer on phones.
Install
npx shadcn add @dgit/sidebar-layoutFirst time? Add the registry to components.json: see Get started.
Depends on @dgit/menu, lucide-react.
sidebar-layout.tsx
import { useEffect, useState, type ReactNode } from "react";
import { ChevronLeft, ChevronRight, ChevronsUpDown, Menu as MenuIcon, Search, X } from "lucide-react";
import { Menu, MenuSeparator } from "@/registry/dgit/ui/menu";
import { cn } from "@/lib/utils";
/**
* A dashboard with a sidebar instead of top pills, for apps with more places than fit in a row.
*
* - The sidebar has a root list; a **group** in it drills in: the sidebar slides over to that
* group's own list, with a back row on top. Groups are nav-only (no page of their own) and
* open their first child.
* - A **layer** does the same for a place in the URL (inside a project: its Overview, Secrets,
* Settings…). While `layer` is set the sidebar shows it; its back row goes to `layer.back.to`.
* - The top bar has a slot on the left (a `ScopeSwitcher` for the project you're in), breadcrumbs
* in the middle, and a slot on the right. Pages don't need their own big title.
* - 256px, fixed from `md` up; below that a menu button opens it as a drawer.
*
* Routing stays yours: pass `pathname`, and `link` to render your router's links.
*
* <SidebarLayout pathname={pathname} link={(p) => <Link to={p.to} {...p} />}
* brand={<Logo />} nav={NAV} layer={project ? projectLayer(project) : null}
* top={{ left: <ScopeSwitcher … />, crumbs }} footer={<AccountMenu side="above" align="start" … />}>
* <Outlet />
* </SidebarLayout>
*/
export interface NavLeaf {
key: string;
label: string;
to: string;
icon?: ReactNode;
/** A link out of the app (docs, the home page): opens as a plain link, never active. */
external?: boolean;
}
export interface NavGroup {
key: string;
label: string;
icon?: ReactNode;
children: NavLeaf[];
}
/** A small muted title between rows, or (`label` omitted) a hairline. */
export interface NavHeading {
key: string;
heading: true;
label?: string;
}
export type NavItem = NavLeaf | NavGroup | NavHeading;
export interface NavLayer {
key: string;
/** Shown in the back row, e.g. the project's name. */
title: string;
back: { label: string; to: string };
/** Above the list, e.g. a status line. */
header?: ReactNode;
items: NavItem[];
}
export interface Crumb {
label: string;
to?: string;
}
export type LinkRenderer = (props: { to: string; className: string; children: ReactNode; onClick?: () => void; "aria-current"?: "page" }) => ReactNode;
const isGroup = (i: NavItem): i is NavGroup => "children" in i;
const isHeading = (i: NavItem): i is NavHeading => "heading" in i;
const leavesOf = (items: NavItem[]): NavLeaf[] => items.flatMap((i) => (isGroup(i) ? i.children : isHeading(i) ? [] : [i]));
/** The leaf a path belongs to: the longest `to` that is the path or a prefix of it at a `/`, so a detail page keeps its list lit. */
export function activeLeaf(pathname: string, items: NavItem[]): NavLeaf | null {
let best: NavLeaf | null = null;
for (const l of leavesOf(items)) {
if (l.external) continue;
const hit = pathname === l.to || pathname.startsWith(l.to.endsWith("/") ? l.to : `${l.to}/`);
if (hit && (!best || l.to.length > best.to.length)) best = l;
}
return best;
}
const defaultLink: LinkRenderer = ({ to, className, children, onClick, ...rest }) => (
<a href={to} className={className} onClick={onClick} {...rest}>
{children}
</a>
);
const rowClass = (active: boolean) =>
cn(
"flex h-9 w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 text-left text-sm no-underline transition-colors [&_svg]:size-4 [&_svg]:shrink-0",
active ? "bg-accent font-medium text-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground",
);
function Rows({ items, pathname, link, onGroup, onNavigate }: { items: NavItem[]; pathname: string; link: LinkRenderer; onGroup?: (g: NavGroup) => void; onNavigate?: () => void }) {
const active = activeLeaf(pathname, items);
return (
<ul className="flex flex-col gap-0.5">
{items.map((i) => {
if (isHeading(i))
return i.label ? (
<li key={i.key} className="px-2.5 pt-4 pb-1 text-xs font-medium text-muted-foreground">
{i.label}
</li>
) : (
<li key={i.key} role="separator" className="my-2 border-t" />
);
if (isGroup(i)) {
const on = i.children.some((c) => c.key === active?.key);
return (
<li key={i.key}>
<button type="button" className={rowClass(on)} onClick={() => onGroup?.(i)}>
{i.icon}
<span className="flex-1 truncate">{i.label}</span>
<ChevronRight className="text-muted-foreground" />
</button>
</li>
);
}
const on = active?.key === i.key;
return (
<li key={i.key}>
{i.external ? (
<a href={i.to} className={rowClass(false)} target="_blank" rel="noopener noreferrer">
{i.icon}
<span className="flex-1 truncate">{i.label}</span>
</a>
) : (
link({
to: i.to,
className: rowClass(on),
onClick: onNavigate,
...(on ? { "aria-current": "page" as const } : {}),
children: (
<>
{i.icon}
<span className="flex-1 truncate">{i.label}</span>
</>
),
})
)}
</li>
);
})}
</ul>
);
}
/** One sliding layer; hidden ones are out of the tab order (inert). */
function Pane({ hidden, from, children }: { hidden: boolean; from: "left" | "right"; children: ReactNode }) {
return (
<div
inert={hidden}
aria-hidden={hidden}
className={cn(
"absolute inset-0 overflow-y-auto px-3 pb-4 transition-[translate,opacity] duration-180 ease-out motion-reduce:transition-none",
hidden && (from === "left" ? "-translate-x-full opacity-0" : "translate-x-full opacity-0"),
)}
>
{children}
</div>
);
}
function BackRow({ label, title, onClick, to, link }: { label: string; title: string; onClick?: () => void; to?: string; link: LinkRenderer }) {
const inner = (
<>
<ChevronLeft />
<span className="sr-only">{label}: </span>
<span className="flex-1 truncate font-medium text-foreground">{title}</span>
</>
);
const cls = cn(rowClass(false), "mb-2");
return to ? link({ to, className: cls, children: inner }) : (
<button type="button" className={cls} onClick={onClick} aria-label={`${label}: ${title}`}>
{inner}
</button>
);
}
export function SidebarLayout({
pathname,
brand,
nav,
layer = null,
top = {},
footer,
link = defaultLink,
children,
}: {
pathname: string;
brand: ReactNode;
nav: NavItem[];
layer?: NavLayer | null;
top?: { left?: ReactNode; crumbs?: Crumb[]; right?: ReactNode };
/** The bottom of the sidebar, usually <AccountMenu side="above" align="start" />. */
footer?: ReactNode;
link?: LinkRenderer;
children: ReactNode;
}) {
// The group whose list is open: the route's own, until the user steps in or out by hand.
const routeGroup = nav.filter(isGroup).find((g) => g.children.some((c) => c.key === activeLeaf(pathname, nav)?.key))?.key ?? null;
const [openGroup, setOpenGroup] = useState<string | null>(routeGroup);
const [synced, setSynced] = useState(routeGroup);
if (synced !== routeGroup) {
setSynced(routeGroup);
setOpenGroup(routeGroup);
}
const [drawer, setDrawer] = useState(false);
useEffect(() => setDrawer(false), [pathname]);
const close = () => setDrawer(false);
const groups = nav.filter(isGroup);
const inGroup = !layer && openGroup ? groups.find((g) => g.key === openGroup) : undefined;
const title = [...(top.crumbs ?? [])].map((c) => c.label).at(-1);
const sidebar = (
<nav aria-label="Main" className="flex h-full flex-col">
<div className="flex h-14 shrink-0 items-center justify-between px-5">
{brand}
<button type="button" className="rounded-full p-1.5 text-muted-foreground hover:text-foreground md:hidden" aria-label="Close menu" onClick={close}>
<X className="size-4" />
</button>
</div>
<div className="relative flex-1 overflow-hidden">
<Pane hidden={Boolean(layer || inGroup)} from="left">
<Rows items={nav} pathname={pathname} link={link} onNavigate={close} onGroup={(g) => setOpenGroup(g.key)} />
</Pane>
{groups.map((g) => (
<Pane key={g.key} hidden={inGroup?.key !== g.key} from="right">
<BackRow label="Back" title={g.label} onClick={() => setOpenGroup(null)} link={link} />
<Rows items={g.children} pathname={pathname} link={link} onNavigate={close} />
</Pane>
))}
{layer && (
<Pane key={layer.key} hidden={false} from="right">
<BackRow label={layer.back.label} title={layer.title} to={layer.back.to} link={link} />
{layer.header}
<Rows items={layer.items} pathname={pathname} link={link} onNavigate={close} />
</Pane>
)}
</div>
{footer && <div className="shrink-0 border-t p-3">{footer}</div>}
</nav>
);
return (
<div className="min-h-dvh bg-background">
{/* Sidebar: fixed from md up, a drawer below. */}
<div
className={cn(
"fixed inset-y-0 left-0 z-40 w-64 border-r bg-background transition-transform duration-200 ease-out motion-reduce:transition-none md:translate-x-0",
drawer ? "translate-x-0 shadow-card" : "-translate-x-full",
)}
>
{sidebar}
</div>
{drawer && <div className="fixed inset-0 z-30 bg-black/30 md:hidden" onClick={close} aria-hidden />}
<div className="md:pl-64">
<header className="sticky top-0 z-20 grid h-14 grid-cols-[auto_1fr_auto] items-center gap-3 border-b bg-background/90 px-4 backdrop-blur md:grid-cols-[1fr_2fr_1fr] md:px-6">
<div className="flex min-w-0 items-center gap-2">
<button type="button" className="-ml-1 rounded-full p-1.5 text-muted-foreground hover:text-foreground md:hidden" aria-label="Open menu" onClick={() => setDrawer(true)}>
<MenuIcon className="size-4" />
</button>
{top.left}
</div>
<Crumbs crumbs={top.crumbs ?? []} link={link} />
<div className="flex items-center justify-end gap-2">{top.right}</div>
</header>
{title && <title>{title}</title>}
<main>{children}</main>
</div>
</div>
);
}
/** "Projects / acme/api / Secrets": parents muted and linked, the page itself in the text color. */
function Crumbs({ crumbs, link }: { crumbs: Crumb[]; link: LinkRenderer }) {
if (!crumbs.length) return <div />;
return (
<ol className="hidden min-w-0 items-center justify-center gap-1.5 text-sm md:flex" aria-label="Breadcrumb">
{crumbs.map((c, i) => {
const last = i === crumbs.length - 1;
return (
<li key={i} className="flex min-w-0 items-center gap-1.5">
{i > 0 && (
<svg viewBox="0 0 16 16" className="size-4 shrink-0 text-border" aria-hidden="true">
<path d="M10.5 2.5 5.5 13.5" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" fill="none" />
</svg>
)}
{last || !c.to ? (
<span className={cn("truncate", last ? "font-medium text-foreground" : "text-muted-foreground")} aria-current={last ? "page" : undefined}>
{c.label}
</span>
) : (
link({ to: c.to, className: "truncate text-muted-foreground no-underline hover:text-foreground", children: c.label })
)}
</li>
);
})}
</ol>
);
}
/**
* The place you're in, and a menu to switch it: "acme/api ⌄" → other projects, "All projects",
* and extra rows (Add project…). Searchable once there are more than seven.
*/
export function ScopeSwitcher({
current,
items,
all,
extra,
link = defaultLink,
label = "Switch",
icon,
}: {
/** What's shown; null for "all" (then `all.label`). */
current: { key: string; label: string } | null;
items: { key: string; label: string; to: string; hint?: string }[];
all?: { label: string; to: string };
/** Rows under the list ("Create project"); a function gets `close` to shut the menu first. */
extra?: ReactNode | ((close: () => void) => ReactNode);
link?: LinkRenderer;
label?: string;
icon?: ReactNode;
}) {
const [q, setQ] = useState("");
const shown = items.filter((i) => i.label.toLowerCase().includes(q.trim().toLowerCase()));
const rowLink = (to: string, children: ReactNode, close: () => void, on: boolean) =>
link({ to, onClick: close, className: cn("flex h-9 items-center gap-2 rounded-md px-2 text-sm no-underline hover:bg-accent", on ? "text-foreground" : "text-muted-foreground hover:text-foreground"), children });
return (
<Menu
label={label}
className="w-72"
trigger={(p) => (
<button
type="button"
onClick={p.toggle}
aria-expanded={p.open}
aria-haspopup="menu"
className="flex h-8 max-w-full min-w-0 cursor-pointer items-center gap-2 rounded-full px-2.5 text-sm font-medium hover:bg-accent [&_svg]:size-4 [&_svg]:shrink-0"
>
{icon}
<span className="truncate">{current?.label ?? all?.label ?? label}</span>
<ChevronsUpDown className="text-muted-foreground" />
</button>
)}
>
{({ close }) => (
<>
{items.length > 7 && (
<div className="mb-1 flex items-center gap-2 border-b px-2 pb-1.5">
<Search className="size-4 text-muted-foreground" />
<input autoFocus value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find…" className="h-8 w-full bg-transparent text-sm outline-none" aria-label="Find" />
</div>
)}
{all && rowLink(all.to, all.label, close, !current)}
{all && <MenuSeparator />}
<div className="max-h-72 overflow-y-auto">
{shown.map((i) =>
rowLink(
i.to,
<>
<span className="flex-1 truncate">{i.label}</span>
{i.hint && <span className="text-xs text-muted-foreground">{i.hint}</span>}
</>,
close,
current?.key === i.key,
),
)}
{!shown.length && <p className="px-2 py-2 text-sm text-muted-foreground">Nothing matches</p>}
</div>
{extra && (
<>
<MenuSeparator />
{typeof extra === "function" ? extra(close) : extra}
</>
)}
</>
)}
</Menu>
);
}