Pattern gallery

Sidebar anatomy

The kit rail as the apps assemble it: grouped nav, a collapse that frees room without losing discovery, and an identity footer.

What this solves

Discovery and gestalt hierarchy. Grouping and per-group counts let a reader find a destination without scanning every item, and the collapsed rail trades labels for room without losing the ability to find anything, since every link keeps a tooltip.
Use it when
  • The primary workspace rail in either app.Speedway's WorkspaceNav (WorkspaceNav.tsx:320-352), the kit Sidebar with the app's own nav data.
  • A narrow viewport or an explicit collapse.Any page below the lg breakpoint: the rail always renders icon-only there, whatever collapsed says.

Rendered

Fixture data; check both themes.
patterns/sidebar-anatomy/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { useState } from "react";
import { Sidebar, type NavGroup } from "@versable-git/ui";
// The rail as the apps assemble it: grouped nav, a per-group count that
// renders only when known, a per-item count that renders only when known,
// and an identity row riding the footer slot. Collapse is driven by the
// kit's own toggle, the same hamburger a real rail exposes.
const nav: NavGroup[] = [
{
label: "Catalog",
count: 15,
items: [
{ label: "Home", href: "#home", Icon: "Dashboard" },
{ label: "Jobs", href: "#jobs", Icon: "Table" },
{ label: "Review", href: "#review", Icon: "Warning" },
],
},
{
label: "Modules",
items: [
{ label: "Workflow", href: "#workflow", Icon: "Refresh" },
{ label: "Scrape", href: "#scrape", Icon: "CloudUpload" },
],
},
];
const itemCounts: Record<string, number> = { "#jobs": 12, "#review": 3 };
export function SidebarAnatomy() {
const [collapsed, setCollapsed] = useState(false);
return (
<div
className={`border-base-300 bg-base-100 h-96 overflow-hidden rounded-lg border transition-[width] duration-200 ${collapsed ? "w-16" : "w-64"}`}
>
<Sidebar
nav={nav}
activeHref="#jobs"
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
renderLink={({ href, className, children, ...rest }) => (
<a href={href} className={className} {...rest}>
<span className="flex w-full items-center justify-between gap-2">
<span className="flex items-center gap-2">{children}</span>
{itemCounts[href ?? ""] != null ? (
<span className="text-base-content/45 text-xs tabular-nums">{itemCounts[href ?? ""]}</span>
) : null}
</span>
</a>
)}
footer={
<span className="flex items-center gap-2 px-1">
<span className="bg-primary/15 text-primary flex size-7 items-center justify-center rounded-full text-xs font-semibold">
JD
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">Jordan Diaz</span>
<span className="text-base-content/50 truncate text-xs">jordan@acme.test</span>
</span>
</span>
}
/>
</div>
);
}

Where it ships

  • speedway/app/components/WorkspaceNav.tsx:320-352the kit Sidebar carrying the app's NavGroup data, a router-aware renderLink, and the account footer
  • speedway/app/components/WorkspaceNav.tsx:71-99the RailLink adapter: prefetch on intent, and revalidate instead of navigate when clicking the current page
  • speedway/app/components/WorkspaceNav.tsx:104-131RailRow: the pending spinner appears only after a 120ms grace period

App-specific: The real renderLink is a router Link with prefetch and a pending spinner, counts come from live queries (no skeleton fallback, deliberately), and collapse state lives in an app context. The frame here bounds the rail for display; apps mount it in the shell.

@versable-git/ui · composites proven in the apps