Skip to content

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

The active sidebar row carries the mark, the last breadcrumb names the page in medium weight, and the page has exactly one h1.

How did I get here

Breadcrumbs show the path from the app's index. Every crumb but the last is a link back.

Where can I go

Sideways through the last crumb's switcher, down through tabs, and anywhere through the command menu on ⌘K.

Decision guide#

Which control moves people where.

Which navigation control
To moveUseCedarline example
Between appsThe app switcher at the top of the sidebarCRM to Contact Center
Between areas of an appSidebar items in groupsCompanies, Deals, Workflows
Up the pathBreadcrumb links in the headerNorthwind Freight back to Companies
Sideways to a siblingThe last crumb's switcherNorthwind Freight to Halcyon Packaging
Between sections of a recordLine Tabs, sticky under the headerOverview, Invoices, Remittances
Between modes of one panelTrack tabs (the default variant)Summary and Transcript on a call
Between saved filters of a listView tabs with countsNeeds approval 12, On hold 7
Between settings sectionsSide tabsApprovals, Payment methods, Remittance
Anywhere, by nameThe Command menu on ⌘KType “Northwind”
Back from a detail on a phoneA back button labelled with the parent‹ Inbox

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

14 invoices, 12 open, $24,516.00 outstanding.

Track tabs: modes of one panel

Northwind asked when INV-20417 would be paid. Nora confirmed it is in Thursday's run for $18,240.00.
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&apos;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.

Showing 12 invoices in Needs approval. Each view is a saved set of filters and a 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.

Approvals

Who signs off on invoices and payment runs.

import { SettingsGroup, SettingsRow } from "@oration/canon/components/settings-section";import { SideTabs, sideTabId } from "@oration/canon/components/side-tabs";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function SettingsNav() {    type Section = "approvals" | "methods" | "remittance" | "portal";    const id = React.useId();    const panelId = `${id}-panel`;    const [section, setSection] = React.useState<Section>("approvals");    const sections: Record<        Section,        { title: string; description: string; rows: string[] }    > = {        approvals: {            title: "Approvals",            description: "Who signs off on invoices and payment runs.",            rows: [                "Require two approvers above $25,000",                "Approve new suppliers before first payment",            ],        },        methods: {            title: "Payment methods",            description: "How Cedarline pays suppliers by default.",            rows: [                "Offer virtual cards to new suppliers",                "Fall back to check when ACH fails",            ],        },        remittance: {            title: "Remittance",            description: "What suppliers receive after each run.",            rows: [                "Attach invoice copies",                "Send from remittance@cedarline.com",            ],        },        portal: {            title: "Supplier portal",            description: "What suppliers can see and change themselves.",            rows: [                "Let suppliers update bank details",                "Show expected payment dates",            ],        },    };    const current = sections[section];    return (        <div className="flex w-full max-w-3xl flex-col gap-6 text-left md:flex-row">            <SideTabs                label="Payables settings"                orientation="vertical"                panelId={panelId}                value={section}                onValueChange={(next) => setSection(next as Section)}                className="md:w-52"                items={[                    { id: "approvals", label: "Approvals", meta: "2 rules" },                    {                        id: "methods",                        label: "Payment methods",                        meta: "ACH default",                    },                    { id: "remittance", label: "Remittance" },                    { id: "portal", label: "Supplier portal" },                ]}            />            <div                id={panelId}                role="tabpanel"                aria-labelledby={sideTabId(section)}                className="flex min-w-0 flex-1 flex-col gap-4"            >                <div>                    <h3 className="text-sm font-semibold">{current.title}</h3>                    <p className="text-13 text-muted-foreground">                        {current.description}                    </p>                </div>                <SettingsGroup>                    {current.rows.map((row, index) => (                        <SettingsRow                            key={`${section}-${row}`}                            inline                            label={row}                            htmlFor={`${id}-${section}-${index}`}                        >                            <Switch                                id={`${id}-${section}-${index}`}                                defaultChecked={index === 0}                                onCheckedChange={(checked) =>                                    toast.add({                                        title: `${row}: ${checked ? "on" : "off"}`,                                    })                                }                            />                        </SettingsRow>                    ))}                </SettingsGroup>            </div>        </div>    );}
Choosing tabs
VariantUse forLook
LineRecord, agent, report and sequence sections. Sticky under the header.No track, 2px ink underline, scrolls with a 2rem edge fade.
TrackModes of one panel or card: Summary and Transcript.32px Well Gray track, 3px inset, raised white active tab.
View tabsSaved views over a list, with counts.28px pills, sliding Well Gray fill.
Side tabsSettings sections and master-detail lists.Vertical 32px rows with optional meta.
Segmented controlA view switch that controls no panel: Table or Board.28px track with a sliding thumb. Not tabs.

The command menu#

⌘K from anywhere: pages, records and actions in one searchable list. It opens without motion, because it is driven by the keyboard.

Command menu

Search pages, records and actions

import { Button } from "@oration/canon/components/button";import {  Command,  CommandDialog,  CommandEmpty,  CommandGroup,  CommandInput,  CommandItem,  CommandList,  CommandShortcut,} from "@oration/canon/components/command";import { Kbd } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import {  Building2Icon,  CalendarDaysIcon,  FileTextIcon,  PlusIcon,  SearchIcon,  SparklesIcon,} from "lucide-react";import * as React from "react";export function CommandMenu() {    const [open, setOpen] = React.useState(false);    const run = (title: string, description?: string) => {        setOpen(false);        toast.add({ title, description });    };    return (        <>            <Button variant="outline" onClick={() => setOpen(true)}>                <SearchIcon data-icon="inline-start" aria-hidden="true" />                Search Cedarline                <Kbd className="ml-1">⌘K</Kbd>            </Button>            <CommandDialog                open={open}                onOpenChange={setOpen}                title="Command menu"                description="Search pages, records and actions"            >                <Command>                    <CommandInput placeholder="Search or type a command" />                    <CommandList>                        <CommandEmpty>No results</CommandEmpty>                        <CommandGroup heading="Go to">                            <CommandItem onSelect={() => run("Invoices")}>                                <FileTextIcon aria-hidden="true" />                                Invoices                                <CommandShortcut>G I</CommandShortcut>                            </CommandItem>                            <CommandItem onSelect={() => run("Payment runs")}>                                <CalendarDaysIcon aria-hidden="true" />                                Payment runs                                <CommandShortcut>G R</CommandShortcut>                            </CommandItem>                            <CommandItem onSelect={() => run("Suppliers")}>                                <Building2Icon aria-hidden="true" />                                Suppliers                                <CommandShortcut>G S</CommandShortcut>                            </CommandItem>                        </CommandGroup>                        <CommandGroup heading="Records">                            <CommandItem                                onSelect={() =>                                    run(                                        "Northwind Freight",                                        "Supplier, 12 open invoices",                                    )                                }                            >                                <Building2Icon aria-hidden="true" />                                Northwind Freight                            </CommandItem>                            <CommandItem                                onSelect={() =>                                    run(                                        "INV-20431",                                        "Halcyon Packaging, on hold",                                    )                                }                            >                                <FileTextIcon aria-hidden="true" />                                INV-20431                            </CommandItem>                        </CommandGroup>                        <CommandGroup heading="Actions">                            <CommandItem                                onSelect={() =>                                    run(                                        "New payment run",                                        "Draft for Tuesday, October 6",                                    )                                }                            >                                <PlusIcon aria-hidden="true" />                                New payment run                            </CommandItem>                            <CommandItem                                onSelect={() =>                                    run("Copilot opens on the right")                                }                            >                                <SparklesIcon aria-hidden="true" />                                Ask Copilot                                <CommandShortcut>⌘J</CommandShortcut>                            </CommandItem>                        </CommandGroup>                    </CommandList>                </Command>            </CommandDialog>        </>    );}
  • Groups in this order: Go to, Records, Actions. Pages show their G shortcut on the right.
  • Results act on Enter and close the menu. Navigation needs no toast; actions answer as they would anywhere else.
  • The sidebar search field opens the same menu, so the mouse path and the keyboard path meet.
  • See Keyboard and commands for the full shortcut map.

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&apos;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>    );}
Navigation below 768px
DesktopBelow 768px
16rem sidebarAn 18rem sheet from the menu button in the header.
28px sidebar rows36px rows at 14px.
Three-pane workspaceOne pane at a time, with a back button labelled with the parent pane.
Side tabsA horizontal strip, or a select at the top of the panel.
Breadcrumb trailThe last crumb only, with its switcher; the back button covers the path.
Hover-revealed row actionsAlways 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.
SuppliersNorthwind Freight
Do. Let the last crumb name the page and switch to its siblings.
HomePayablesSuppliersSUP-1001
Don't. Repeat the page name as a link to itself, or end the trail on an ID.
Do. Keep the rail neutral. The only color in it is the active mark.
Don't. Color-code sidebar sections or icons by area. Categorical hues are for option values, not navigation.

Accessibility#

Navigation is landmarks, names and the keyboard.

  • The sidebar is a nav named for the app (CRM navigation); breadcrumbs are a second nav named 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-label and 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.
Keyboard interactions
KeysAction
⌘KOpen the command menu.
⌘BCollapse or expand the sidebar.
⌘JOpen the Copilot.
⌘1–4Switch app. G then 1 to 4 works too.
GCGo 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.

Components involved
ComponentRole here
SidebarThe rail, groups, items, counts and the gliding mark.
BreadcrumbThe path in the header.
PopoverThe app switcher and the sibling switcher.
Command menu⌘K, and the searchable list inside the switcher.
TabsLine tabs for records, the track for modes.
Side tabsSettings sections.
Segmented controlView switches that control no panel.
Fluid hoverOne highlight that glides between rows.
SheetThe sidebar below 768px.