Navigation Patterns
Overview
Navigation is how users build a mental map of your SaaS app. The core set: a sidebar for primary sections, tabs for views within a page, breadcrumbs for depth, and a command palette for power users. Mark up each with semantic landmarks and correct active state so both mouse and keyboard users stay oriented.
When to use
- Building the primary sidebar or in-page tabs.
- Adding ⌘K quick navigation/search.
- Users get lost or can't tell where they are.
Sidebar with active state
tsx"use client"; import Link from "next/link"; import { usePathname } from "next/navigation"; import { cn } from "@/lib/utils"; const items = [ { href: "/dashboard", label: "Dashboard", icon: Home }, { href: "/invoices", label: "Invoices", icon: FileText }, { href: "/settings", label: "Settings", icon: Settings }, ]; export function Sidebar() { const pathname = usePathname(); return ( <nav aria-label="Primary" className="flex flex-col gap-1 p-3"> {items.map(({ href, label, icon: Icon }) => { const active = pathname === href || pathname.startsWith(href + "/"); return ( <Link key={href} href={href} aria-current={active ? "page" : undefined} className={cn( "flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", active ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", )} > <Icon className="h-4 w-4" aria-hidden /> {label} </Link> ); })} </nav> ); }
aria-current="page" tells screen readers which item is active — color alone isn't enough. Match the active route with startsWith so nested pages keep their parent highlighted.
Command palette (⌘K)
tsximport { CommandDialog, CommandInput, CommandList, CommandItem, CommandGroup } from "@/components/ui/command"; export function CommandMenu() { const [open, setOpen] = React.useState(false); React.useEffect(() => { const down = (e: KeyboardEvent) => { if (e.key === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); setOpen((o) => !o); } }; document.addEventListener("keydown", down); return () => document.removeEventListener("keydown", down); }, []); return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Search or jump to…" /> <CommandList> <CommandGroup heading="Navigation"> <CommandItem onSelect={() => go("/invoices")}>Invoices</CommandItem> <CommandItem onSelect={() => go("/settings")}>Settings</CommandItem> </CommandGroup> </CommandList> </CommandDialog> ); }
cmdk (under shadcn Command) gives fuzzy search, arrow-key nav, and focus management for free.
Tabs & breadcrumbs
- Tabs for switching views of the same resource (Overview / Activity / Settings). Use shadcn
Tabs; sync the active tab to?tab=for deep links. - Breadcrumbs for hierarchy (
Workspace › Project › Settings); wrap in<nav aria-label="Breadcrumb">and mark the last crumbaria-current="page".
Pitfalls
- Active state by color only — add
aria-currentand ideally weight/indicator. - Exact-match active route breaks on nested pages — use
startsWith. - Command palette without focus trap/Esc — use the
Commanddialog primitive. - Tabs that lose state on refresh — persist to the URL.
- No
aria-labelon multiple<nav>s — screen readers can't distinguish them.
Hand-off
Wayfinding that sits inside the responsive-layout shell; deep flows route into settings-pages, billing-and-pricing, and dashboard-layout.


