Navigation and wayfinding
The sidebar, the header breadcrumbs, sibling switchers, tabs and how people always know where they are.
The problem#
Four apps, hundreds of records, one shell. People need to know where they are, how they got there and where they can go next, without reading.
Oration is a CRM, an Agents Platform, a Contact Center and Ticketing in one window. Someone can start on a supplier in the CRM, open the call Nora took about it, and end up in a ticket. If each app invents its own navigation, every jump is a small reorientation. If any screen hides where it sits, people retrace their steps through the sidebar instead of moving sideways.
The solution in Canon#
One shell for every app: a sidebar that names the app and marks the page, a 48px header whose breadcrumbs show the path and switch between siblings, and tabs for the parts of a page.
Where am I
How did I get here
Where can I go
Decision guide#
Which control moves people where.
| To move | Use | Cedarline example |
|---|---|---|
| Between apps | The app switcher at the top of the sidebar | CRM to Contact Center |
| Between areas of an app | Sidebar items in groups | Companies, Deals, Workflows |
| Up the path | Breadcrumb links in the header | Northwind Freight back to Companies |
| Sideways to a sibling | The last crumb's switcher | Northwind Freight to Halcyon Packaging |
| Between sections of a record | Line Tabs, sticky under the header | Overview, Invoices, Remittances |
| Between modes of one panel | Track tabs (the default variant) | Summary and Transcript on a call |
| Between saved filters of a list | View tabs with counts | Needs approval 12, On hold 7 |
| Between settings sections | Side tabs | Approvals, Payment methods, Remittance |
| Anywhere, by name | The Command menu on ⌘K | Type “Northwind” |
| Back from a detail on a phone | A back button labelled with the parent | ‹ Inbox |
The header and breadcrumbs#
Every workspace page opens with the 48px sticky header: sidebar toggle, a 16px divider, breadcrumbs, and page actions at the right with 6px gaps.
- Breadcrumbs are 13px Slate Meta links. The last crumb is the current page in medium Graphite Ink and is not a link.
- Every detail page passes its siblings to the last crumb:
Crumb.switcherin the app header, with a preset such as"deals"or an item list. - The switcher opens a searchable list with the current item checked. Arrow keys move, Enter goes.
- Dense list pages don't show a visible h1; they pass
headingso the header renders one for screen readers. - Page actions sit at the right: outline or ghost, at most one filled, 6px apart.
The switcher lives in apps/web/src/components/shell/breadcrumb-switcher.tsx, not in packages/canon, and the Breadcrumb primitive defaults to 14px with a normal-weight current page. The header overrides both. See Breadcrumb.
Tabs#
Line tabs for the sections of a record, the track for modes of one panel, view tabs for saved slices of a list, side tabs for settings.
Line and track
Line tabs have no track; the active tab gets a 2px ink underline, and the row scrolls with a faded edge. The track is a 32px Well Gray pill with a raised active tab.
Line tabs: the sections of a record
Track tabs: modes of one panel
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@oration/canon/components/tabs";export function TabVariants() { return ( <div className="flex w-full max-w-2xl flex-col gap-8 text-left"> <div className="flex flex-col gap-2"> <p className="text-xs text-muted-foreground"> Line tabs: the sections of a record </p> <Tabs defaultValue="invoices" className="gap-0"> <TabsList variant="line" scrollable className="h-10 w-full border-b border-border px-1" > <TabsTrigger value="overview" className="px-2"> Overview </TabsTrigger> <TabsTrigger value="invoices" className="px-2"> Invoices <span className="text-xs font-normal text-muted-foreground tabular-nums"> 14 </span> </TabsTrigger> <TabsTrigger value="remittances" className="px-2"> Remittances <span className="text-xs font-normal text-muted-foreground tabular-nums"> 126 </span> </TabsTrigger> <TabsTrigger value="w9" className="px-2"> W-9 </TabsTrigger> <TabsTrigger value="activity" className="px-2"> Activity </TabsTrigger> </TabsList> <TabsContent value="overview" className="pt-4 text-13 text-muted-foreground" > Net 30, paid by ACH, supplier since March 2023. </TabsContent> <TabsContent value="invoices" className="pt-4 text-13 text-muted-foreground" > 14 invoices, 12 open, $24,516.00 outstanding. </TabsContent> <TabsContent value="remittances" className="pt-4 text-13 text-muted-foreground" > 126 remittances since 2023. The last went out Sep 28 at 9:14 AM. </TabsContent> <TabsContent value="w9" className="pt-4 text-13 text-muted-foreground" > W-9 on file, signed Jan 12, 2026. </TabsContent> <TabsContent value="activity" className="pt-4 text-13 text-muted-foreground" > Nora took a call from Northwind on Sep 25 about INV-20388. </TabsContent> </Tabs> </div> <div className="flex flex-col gap-2"> <p className="text-xs text-muted-foreground"> Track tabs: modes of one panel </p> <div className="rounded-xl bg-card p-4 shadow-border"> <Tabs defaultValue="summary"> <TabsList> <TabsTrigger value="summary" className="px-2.5"> Summary </TabsTrigger> <TabsTrigger value="transcript" className="px-2.5"> Transcript </TabsTrigger> </TabsList> <TabsContent value="summary" className="pt-2 text-13 text-muted-foreground" > Northwind asked when INV-20417 would be paid. Nora confirmed it is in Thursday's run for $18,240.00. </TabsContent> <TabsContent value="transcript" className="pt-2 font-mono text-xs text-muted-foreground" > 00:04 Nora: Thanks for calling Cedarline payables. 00:09 Caller: Hi, checking on invoice 20417. </TabsContent> </Tabs> </div> </div> </div> );}View tabs#
28px pills over a list. A Well Gray fill slides between them on a 0.3s spring with no bounce, and counts trail in 12px Slate Meta. Each view is a saved filter and sort; see Search, filter and sort.
import { spring } from "@oration/canon/lib/springs";import { cn } from "@oration/canon/lib/utils";import { motion } from "motion/react";import * as React from "react";export function ViewTabs() { type View = "all" | "approval" | "hold" | "overdue"; const id = React.useId(); const [view, setView] = React.useState<View>("approval"); const views: { id: View; label: string; count: number }[] = [ { id: "all", label: "All invoices", count: 248 }, { id: "approval", label: "Needs approval", count: 12 }, { id: "hold", label: "On hold", count: 7 }, { id: "overdue", label: "Overdue", count: 3 }, ]; return ( <div className="flex w-full max-w-2xl flex-col gap-3 text-left"> <div role="tablist" aria-label="Invoice views" className="no-scrollbar flex min-w-0 items-center gap-0.5 overflow-x-auto" > {views.map((item) => { const selected = item.id === view; return ( <button key={item.id} type="button" role="tab" id={`${id}-${item.id}`} aria-selected={selected} aria-controls={`${id}-panel`} tabIndex={selected ? 0 : -1} onClick={() => setView(item.id)} onKeyDown={(event) => { if ( event.key !== "ArrowRight" && event.key !== "ArrowLeft" ) return; event.preventDefault(); const index = views.findIndex( (entry) => entry.id === view, ); const next = views[ (index + (event.key === "ArrowRight" ? 1 : -1) + views.length) % views.length ]; if (!next) return; setView(next.id); document .getElementById(`${id}-${next.id}`) ?.focus(); }} className={cn( "relative flex h-7 shrink-0 items-center gap-1.5 rounded-md px-2 text-13 whitespace-nowrap outline-none transition-colors duration-150 focus-visible:ring-3 focus-visible:ring-ring/40", selected ? "font-medium text-foreground" : "text-muted-foreground hover:text-foreground", )} > {selected ? ( <motion.span layoutId={`${id}-indicator`} className="absolute inset-0 rounded-md bg-muted" transition={{ type: "spring", duration: 0.3, bounce: 0, }} /> ) : null} <span className="relative">{item.label}</span> <span className="relative text-xs font-normal text-muted-foreground tabular-nums"> {item.count} </span> </button> ); })} </div> <div id={`${id}-panel`} role="tabpanel" aria-labelledby={`${id}-${view}`} className="rounded-xl bg-card px-4 py-3 text-13 text-muted-foreground shadow-border" > Showing{" "} <span className="font-medium text-foreground tabular-nums"> {views.find((item) => item.id === view)?.count} </span>{" "} invoices in {views.find((item) => item.id === view)?.label}. Each view is a saved set of filters and a sort. </div> </div> );}Side tabs for settings#
A vertical list beside the panel, with an optional meta line. It wraps into a horizontal strip below 1280px unless the orientation is pinned.
| Variant | Use for | Look |
|---|---|---|
| Line | Record, agent, report and sequence sections. Sticky under the header. | No track, 2px ink underline, scrolls with a 2rem edge fade. |
| Track | Modes of one panel or card: Summary and Transcript. | 32px Well Gray track, 3px inset, raised white active tab. |
| View tabs | Saved views over a list, with counts. | 28px pills, sliding Well Gray fill. |
| Side tabs | Settings sections and master-detail lists. | Vertical 32px rows with optional meta. |
| Segmented control | A view switch that controls no panel: Table or Board. | 28px track with a sliding thumb. Not tabs. |
On a phone#
Below 768px the sidebar becomes a sheet, multi-pane layouts collapse to one pane, and every detail screen gets a back button named for where it goes.
One pane with a back affordance
Open a conversation. The header swaps the menu button for ‹ Inbox, a 36px target that names the parent.
Inbox
import { Button } from "@oration/canon/components/button";import { MonogramTile } from "@oration/canon/components/monogram-tile";import type { TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { exit, spring } from "@oration/canon/lib/springs";import { ChevronLeftIcon, ChevronRightIcon, MenuIcon } from "lucide-react";import { AnimatePresence, motion } from "motion/react";import * as React from "react";export function MobileBack() { type Thread = { id: string; from: string; color: TagColor; subject: string; time: string; }; const threads: Thread[] = [ { id: "t1", from: "Northwind Freight", color: "blue", subject: "Where is payment for INV-20417?", time: "9:42 AM", }, { id: "t2", from: "Halcyon Packaging", color: "teal", subject: "W-9 attached", time: "9:15 AM", }, { id: "t3", from: "Orchard Street Foods", color: "orange", subject: "Remittance for PAY-1185", time: "Yesterday", }, ]; const [openId, setOpenId] = React.useState<string | null>(null); const thread = threads.find((item) => item.id === openId); return ( <div className="relative flex h-[30rem] w-[22rem] max-w-full flex-col overflow-hidden rounded-[28px] bg-background text-left shadow-border"> <AnimatePresence initial={false} mode="popLayout"> {thread ? ( <motion.div key="detail" initial={{ opacity: 0, x: 24 }} animate={{ opacity: 1, x: 0, transition: spring.moderate, }} exit={{ opacity: 0, x: 24, transition: exit.moderate }} className="flex h-full flex-col" > <header className="flex h-12 items-center gap-1 border-b border-border px-2"> <Button variant="ghost" className="h-9 gap-0.5 px-2 text-muted-foreground" onClick={() => setOpenId(null)} > <ChevronLeftIcon data-icon="inline-start" aria-hidden="true" /> Inbox </Button> </header> <div className="flex flex-col gap-3 p-4"> <div className="flex items-center gap-2.5"> <MonogramTile name={thread.from} color={thread.color} size="lg" /> <div className="min-w-0"> <h3 className="truncate text-sm font-semibold"> {thread.from} </h3> <p className="text-xs text-muted-foreground"> {thread.time} </p> </div> </div> <p className="text-sm font-medium"> {thread.subject} </p> <p className="rounded-[10px] bg-muted/70 px-3 py-2.5 text-sm text-muted-foreground"> Hi, we haven't seen this one land yet. Can you confirm the date it goes out? </p> </div> </motion.div> ) : ( <motion.div key="list" initial={{ opacity: 0, x: -24 }} animate={{ opacity: 1, x: 0, transition: spring.moderate, }} exit={{ opacity: 0, x: -24, transition: exit.moderate }} className="flex h-full flex-col" > <header className="flex h-12 items-center gap-2 border-b border-border px-2"> <Button variant="ghost" size="icon" aria-label="Open navigation" className="size-9 text-muted-foreground" onClick={() => toast.add({ title: "The sidebar opens as an 18rem sheet", }) } > <MenuIcon aria-hidden="true" /> </Button> <h3 className="text-sm font-medium">Inbox</h3> </header> <ul className="flex flex-col"> {threads.map((item) => ( <li key={item.id} className="border-b border-border" > <button type="button" onClick={() => setOpenId(item.id)} className="flex min-h-16 w-full items-center gap-3 px-4 py-3 text-left outline-none hover:bg-surface focus-visible:bg-surface" > <MonogramTile name={item.from} color={item.color} size="lg" /> <span className="flex min-w-0 flex-1 flex-col"> <span className="flex items-baseline justify-between gap-2"> <span className="truncate text-sm font-medium"> {item.from} </span> <span className="shrink-0 text-xs text-muted-foreground"> {item.time} </span> </span> <span className="truncate text-13 text-muted-foreground"> {item.subject} </span> </span> <ChevronRightIcon aria-hidden="true" className="size-4 shrink-0 text-subtle-foreground" /> </button> </li> ))} </ul> </motion.div> )} </AnimatePresence> </div> );}| Desktop | Below 768px |
|---|---|
| 16rem sidebar | An 18rem sheet from the menu button in the header. |
| 28px sidebar rows | 36px rows at 14px. |
| Three-pane workspace | One pane at a time, with a back button labelled with the parent pane. |
| Side tabs | A horizontal strip, or a select at the top of the panel. |
| Breadcrumb trail | The last crumb only, with its switcher; the back button covers the path. |
| Hover-revealed row actions | Always visible, at least 32px. |
Wayfinding rules#
Small habits that keep people oriented.
- Exactly one sidebar row is active: the page you're on. A collapsed group that hides it shows a dot on its label instead.
- The breadcrumb's last label, the page h1 and the browser title agree word for word.
- Tabs, views and filters live in the URL, so a link reproduces the screen and Back returns to the previous one.
- Opening a record from a list keeps the list one Back away. Sheets keep the list in view behind them.
- Never navigate on hover, and never move focus to a new page without a click or key.
- Each app gets its character from layout and density, not from its own navigation. The shell is the same everywhere.
Accessibility#
Navigation is landmarks, names and the keyboard.
- The sidebar is a
navnamed for the app (CRM navigation); breadcrumbs are a secondnavnamed Breadcrumb. - The current page carries
aria-current="page"in both the sidebar and the breadcrumbs. - Icon-only controls (sidebar toggle, Copilot, settings) have an
aria-labeland a tooltip. - The switcher trigger's accessible name adds , switch supplier so its purpose is heard.
- Tabs use the tab roles from Base UI: arrow keys move between tabs, Tab moves into the panel.
- The command menu is a dialog with a hidden title and description, and opens without animation.
- Back buttons on phones are at least 36px tall and say where they go.
| Keys | Action |
|---|---|
| ⌘K | Open the command menu. |
| ⌘B | Collapse or expand the sidebar. |
| ⌘J | Open the Copilot. |
| ⌘1–4 | Switch app. G then 1 to 4 works too. |
| GC | Go to Companies. Each top-level page has a G shortcut. |
| ↑↓ | Move between sidebar rows and switcher options. |
| ←→ | Move between tabs and view tabs. |
Components involved#
The parts this pattern is built from.
| Component | Role here |
|---|---|
| Sidebar | The rail, groups, items, counts and the gliding mark. |
| Breadcrumb | The path in the header. |
| Popover | The app switcher and the sibling switcher. |
| Command menu | ⌘K, and the searchable list inside the switcher. |
| Tabs | Line tabs for records, the track for modes. |
| Side tabs | Settings sections. |
| Segmented control | View switches that control no panel. |
| Fluid hover | One highlight that glides between rows. |
| Sheet | The sidebar below 768px. |