Keyboard and commands
The command menu, global shortcuts, J and K moves and how shortcuts are shown.
The problem#
Cedarline's support team works hundreds of calls, tickets and approvals a day. Every trip to the mouse costs a second, and every app that invents its own keys costs a week of relearning.
Keyboard support also breaks in quiet ways: a single-letter shortcut that fires while someone types an email, a palette that animates on every open, focus that drops to the page after a row disappears. Canon fixes the keys once for the whole suite and says how each one is shown, guarded and animated.
The solution#
One command menu, one set of global keys, the same list keys in every app, and four rules for all of them.
| Rule | What it means |
|---|---|
| Modifiers anywhere, letters outside fields | ⌘ chords work even while typing. Single keys (J, K, A, S, G, ?) fire only when focus isn't in an input, textarea, select, combobox or rich text, and not while a dialog or menu is open. |
| Every key has a pointer path | A shortcut speeds up something people can also click. Nothing is keyboard-only, and nothing is pointer-only. |
| Keys are shown where they're used | In the tooltip of the control, on the button, in the menu's shortcut slot, and in the ? dialog. Never only in documentation. |
| Keyboard surfaces don't animate | The command menu opens and closes instantly, and J and K moves are instant. People repeat these dozens of times an hour. |
Global shortcuts#
Read from the shell's shortcuts dialog, which opens on ? anywhere outside a text field.
General#
| Keys | Action |
|---|---|
| ⌘K | Open command menu |
| ⌘J | Ask Copilot |
| ⌘1–4 | Switch app |
| G1–4 | Switch app, alternative |
| ⌘B | Toggle sidebar |
| ? | Show shortcuts |
Go to#
Press G, then a letter within 900ms. The letters come from each app's nav file, so they change with the app you're in. G then a number switches app.
| App | Keys after G |
|---|---|
| CRM | HHomeIInboxTTasksCCompaniesPPeopleDDealsSSequencesWWorkflowsAAgentsMMeetingsRReports |
| Agents Platform | HHomeRReportsBOverviewAAgentsFFlowsKKnowledge baseYHistory |
| Contact Center | WWorkspaceQQueuesCCustomersTTeam |
| Ticketing | UUnassignedMMy ticketsAAll ticketsVViewsRReports |
Browsers may keep ⌘1 to ⌘4
Some browsers switch tabs on ⌘1 to ⌘4 before the page sees the key. G then 1 to 4 works everywhere, so the dialog lists both.
Page and list shortcuts#
Keys that belong to a surface, active while that surface is on screen.
| Keys | Action |
|---|---|
| JK | Next and previous item |
| ↵ | Open selected item |
| A | Approve in Needs your call |
| S | Skip in Needs your call |
| E | Mark inbox item done |
| ⌘S | Save from the save bar while a form has unsaved changes. |
| Esc | Close the open overlay, cancel an inline edit, or clear a list's selection. One layer at a time. |
| / | Open search in Canon when focus isn't in a field. |
Search on / is Canon only, for now
The docs bind / to search. Product list pages don't yet; their search fields are reached with ⌘K or Tab. When a list binds it, it focuses that list's search field, never the command menu.
J and K in lists#
Home's Needs your call, the inbox and the ticket list all move with J and K and act on the active item with single letters.
Move and decide with the keyboard
Click a row or tab into the list, then press J and K to move, A to approve, S to skip and Enter to open. Focus moves to the next item when one is resolved.
Needs your call
JKto move,Ato approve,Sto skip
- Update Halcyon's remit-to emailNoraFrom a call with Wen Zhou
- Hold INV-20447 until Bayline sends a W-9Matching agentW-9 expired Sep 1
- Pay Northwind Freight early for 2%Cash agentSaves $364.80
- Call Orchard Street about a short paymentNoraINV-20438 paid $120.00 short
import { Button } from "@oration/canon/components/button";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import * as React from "react";export function JkList() { const seed = [ { id: "a1", agent: "Nora", title: "Update Halcyon's remit-to email", meta: "From a call with Wen Zhou", }, { id: "a2", agent: "Matching agent", title: "Hold INV-20447 until Bayline sends a W-9", meta: "W-9 expired Sep 1", }, { id: "a3", agent: "Cash agent", title: "Pay Northwind Freight early for 2%", meta: "Saves $364.80", }, { id: "a4", agent: "Nora", title: "Call Orchard Street about a short payment", meta: "INV-20438 paid $120.00 short", }, ]; const [items, setItems] = React.useState(seed); const [activeId, setActiveId] = React.useState<string | null>( seed[0]?.id ?? null, ); const refs = React.useRef(new Map<string, HTMLLIElement>()); const focusRow = (id: string | null) => { setActiveId(id); if (id) refs.current.get(id)?.focus(); }; const resolve = (id: string, outcome: "Approved" | "Skipped") => { const index = items.findIndex((item) => item.id === id); const item = items[index]; if (!item) return; const rest = items.filter((entry) => entry.id !== id); const next = rest[index] ?? rest[index - 1] ?? null; setItems(rest); setActiveId(next?.id ?? null); window.requestAnimationFrame(() => { if (next) refs.current.get(next.id)?.focus(); }); toast.add({ type: outcome === "Approved" ? "success" : undefined, title: `${outcome}: ${item.title}`, actionProps: { children: "Undo", onClick: () => { setItems((current) => { const copy = [...current]; copy.splice(index, 0, item); return copy; }); setActiveId(item.id); }, }, }); }; const onKeyDown = (event: React.KeyboardEvent<HTMLUListElement>) => { if (event.metaKey || event.ctrlKey || event.altKey) return; const index = items.findIndex((item) => item.id === activeId); const current = items[index]; const key = event.key.toLowerCase(); if ((key === "j" || key === "arrowdown") && index < items.length - 1) focusRow(items[index + 1]?.id ?? null); else if ((key === "k" || key === "arrowup") && index > 0) focusRow(items[index - 1]?.id ?? null); else if (key === "a" && current) resolve(current.id, "Approved"); else if (key === "s" && current) resolve(current.id, "Skipped"); else if (key === "enter" && current) toast.add({ title: `Opened ${current.title}` }); else return; event.preventDefault(); }; return ( <div className="flex w-full max-w-lg flex-col gap-2 text-left"> <div className="flex items-baseline justify-between gap-3"> <h3 id="jk-heading" className="text-sm font-semibold text-foreground" > Needs your call </h3> <p className="hidden items-center gap-1.5 text-xs text-muted-foreground md:flex"> <KbdGroup> <Kbd>J</Kbd> <Kbd>K</Kbd> </KbdGroup> to move, <Kbd>A</Kbd> to approve, <Kbd>S</Kbd> to skip </p> </div> {items.length === 0 ? ( <p className="rounded-[10px] bg-muted/70 px-3 py-6 text-center text-13 text-muted-foreground"> You're all caught up. Undo brings a decision back. </p> ) : ( <ul aria-labelledby="jk-heading" aria-keyshortcuts="J K A S Enter" onKeyDown={onKeyDown} className="flex flex-col overflow-hidden rounded-xl bg-card shadow-border" > {items.map((item) => { const active = item.id === activeId; return ( <li key={item.id} ref={(element) => { if (element) refs.current.set(item.id, element); else refs.current.delete(item.id); }} tabIndex={active ? 0 : -1} aria-current={active || undefined} onFocus={() => setActiveId(item.id)} className={cn( "flex min-h-12 items-center gap-3 border-b border-border px-4 py-2 outline-none last:border-b-0 focus-visible:ring-3 focus-visible:ring-ring/40 focus-visible:ring-inset", active ? "bg-primary/6" : "hover:bg-surface", )} > <span className="flex min-w-0 flex-1 flex-col"> <span className="truncate text-13 font-medium text-foreground"> {item.title} </span> <span className="flex gap-3 text-xs text-muted-foreground"> <span>{item.agent}</span> <span>{item.meta}</span> </span> </span> <Button variant="ghost" size="xs" tabIndex={-1} onClick={() => resolve(item.id, "Skipped")} > Skip </Button> <Button variant={active ? "default" : "outline"} size="xs" tabIndex={-1} onClick={() => resolve(item.id, "Approved")} > Approve </Button> </li> ); })} </ul> )} </div> );}- The active item takes the 6% indigo tint, the same as a selected row, and holds focus with a visible ring.
- Only the active item's primary action is filled, so the one filled button follows the cursor.
- Moves are instant. No scroll smoothing, no highlight sliding between rows.
- J and K also accept ↓ and ↑. Enter opens; Esc leaves the item without acting.
- A hint row with Kbd sits above the list from 768px up, where people have keyboards: J K to move, A to approve, S to skip.
Showing shortcuts#
Keys are drawn with Kbd: a 20px Well Gray keycap in Geist Sans. The action's name comes first and the keys follow.
Keys in tooltips, menus and buttons
Hover the icon buttons for their tooltips, open the menu, and press ⌘S in the field after changing it.
import { Button } from "@oration/canon/components/button";import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuShortcut, DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { Input } from "@oration/canon/components/input";import { Kbd, KbdGroup } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, PanelLeftIcon, SearchIcon, SparklesIcon } from "lucide-react";import * as React from "react";export function ShortcutHints() { const [email, setEmail] = React.useState("ap@cedarline.com"); const [saved, setSaved] = React.useState("ap@cedarline.com"); const dirty = email !== saved; const save = () => { setSaved(email); toast.add({ type: "success", title: "Remit-from address saved" }); }; return ( <div className="flex w-full max-w-lg flex-col gap-6 text-left"> <div className="flex items-center gap-1 rounded-xl bg-card p-1.5 shadow-border"> {[ { label: "Search", keys: ["⌘", "K"], icon: SearchIcon }, { label: "Ask Copilot", keys: ["⌘", "J"], icon: SparklesIcon, }, { label: "Toggle sidebar", keys: ["⌘", "B"], icon: PanelLeftIcon, }, ].map((item) => ( <Tooltip key={item.label}> <TooltipTrigger render={ <Button variant="ghost" size="icon-sm" aria-label={item.label} aria-keyshortcuts={`Meta+${item.keys[1]}`} onClick={() => toast.add({ title: item.label, description: `Press ${item.keys.join("")} next time.`, }) } /> } > <item.icon aria-hidden="true" /> </TooltipTrigger> <TooltipContent> {item.label} <KbdGroup> {item.keys.map((key) => ( <Kbd key={key}>{key}</Kbd> ))} </KbdGroup> </TooltipContent> </Tooltip> ))} <DropdownMenu> <DropdownMenuTrigger render={ <Button variant="ghost" size="icon-sm" aria-label="More actions" className="ml-auto" /> } > <MoreHorizontalIcon aria-hidden="true" /> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => toast.add({ title: "Keyboard shortcuts" }) } > Keyboard shortcuts <DropdownMenuShortcut>?</DropdownMenuShortcut> </DropdownMenuItem> <DropdownMenuItem onClick={() => toast.add({ title: "Copilot" })} > Ask Copilot <DropdownMenuShortcut>⌘J</DropdownMenuShortcut> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> <form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); save(); }} onKeyDown={(event) => { if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "s" ) { event.preventDefault(); if (dirty) save(); } }} > <label htmlFor="kbd-remit" className="text-sm font-medium text-foreground" > Remit-from address </label> <div className="flex gap-2"> <Input id="kbd-remit" value={email} onChange={(event) => setEmail(event.target.value)} /> <Button type="submit" disabled={!dirty} aria-keyshortcuts="Meta+S" > Save <KbdGroup> <Kbd className="bg-primary-foreground/15 text-primary-foreground"> ⌘ </Kbd> <Kbd className="bg-primary-foreground/15 text-primary-foreground"> S </Kbd> </KbdGroup> </Button> </div> <p className="text-xs text-muted-foreground"> Change the address, then press ⌘S while the field has focus. </p> </form> </div> );}| Place | How |
|---|---|
| Tooltip | Label, then a KbdGroup. Kbd recolors itself on Graphite Ink. |
| Button | The keys after the label, as on Save ⌘ S in the save bar. On a filled button pass bg-primary-foreground/15 text-primary-foreground. |
| Menu item | The menu's own right-aligned DropdownMenuShortcut or CommandShortcut slot, in Slate Meta. |
| Hint row | 12px Slate Meta words with Kbd between them, beside the list they drive, hidden below 768px. |
| Shortcuts dialog | Every key the current app answers to, grouped, on ?. |
The Machine Mono Rule
cmd+jThe menu shortcut slot is letter-spaced
DropdownMenuShortcut and CommandShortcut set tracking-widest, which the Sentence Case Rule doesn't allow for captions. Until they change, keep menu shortcuts to single glyphs and short chords such as ? and ⌘J.
Keyboard surfaces don't animate#
Motion helps people follow a change they didn't cause. On surfaces people drive with keys, they already know what happened, so motion only slows them down.
The Three Springs Rule
spring.fast, spring.moderate and spring.slow, with exits one tier faster that never bounce. No other timings exist except the skeleton reveal, shimmer, the voice orb and meter fills, and keyboard-driven surfaces don't animate.- The command menu sets
data-no-motion, which turns off the dialog's 200ms scale and fade and its overlay fade. - J and K moves change the active row in the same frame. Don't animate the tint or scroll smoothly to the row.
- Highlight changes inside menus and the palette are instant; only pointer hover color eases.
- A surface that opens from the keyboard and from the pointer keeps the same rule: no motion on either.
Guarding single keys#
Global handlers check where focus is before they act. The shell's guard, which every page handler reuses:
export function isTypingTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; return ( target.isContentEditable || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.getAttribute("role") === "combobox" );}- Skip single keys when any modifier other than Shift is held, when
isTypingTarget(event.target)is true, or when a dialog or menu is open ([role=dialog][data-open],[role=menu][data-open]). - Call
preventDefault()only for keys you handled, so the browser and assistive tech keep everything else. - Scope page keys to the page that owns them, and remove the listener when it unmounts.
- Never bind a key the browser or screen readers rely on without a modifier: Tab, the arrow keys outside your own widget, Space on the page.
Focus management#
Focus is never lost. When something opens, closes or disappears, focus lands somewhere that makes the next key press useful.
| When | Focus goes to |
|---|---|
| A dialog or sheet opens | Its first field, or Cancel in a destructive confirmation. |
| A dialog, sheet, menu or the command menu closes | The control that opened it. Base UI returns it. |
| The active list item is approved, skipped or archived | The next item, or the previous one at the end of the list. |
| An approval card resolves | Its Undo button, so the decision can be reversed straight away. |
| A page intro is dismissed | A screen-reader note where the intro was. |
| An inline edit commits with Enter or cancels with Esc | The text it edited, now in read mode. |
| A toast with Undo appears | Stays where it was. The toast is announced and reachable, never focused. |
Accessibility#
Shortcuts are an accelerator on top of a fully keyboard-operable product, not a replacement for it.
- Declare shortcuts on the controls they trigger with
aria-keyshortcuts(Meta+S,J K A S Enter). - Single-key shortcuts are active only while focus is outside text fields, which keeps them from firing under dictation and screen-reader typing.
- Every focusable thing shows the indigo focus ring. Rows in J and K lists draw it inset so it isn't clipped.
- Roving lists keep one tab stop: Tab enters on the active row and leaves the list; J and K move inside it.
- Hint rows are visual help. The shortcuts dialog and
aria-keyshortcutscarry the same information to assistive tech.
Components#
The parts this pattern is built from.
- Command menuThe ⌘K palette: search, jump and run commands from the keyboard.
- KbdA keyboard key or chord, used in tooltips, menus and shortcut hints.
- TooltipA short Graphite Ink label that names an icon or explains a control, after the app's 400ms delay.
- Dropdown menuA menu of actions or options that opens from a button.
- Save barThe sticky unsaved-changes bar with a change count, Discard, Save and ⌘S.
- SidebarThe app rail: workspace switcher, search, grouped navigation and the user menu.
- Approval cardHuman in the loop: a proposed action with its fields, Approve, Edit and Skip.