Skip to content

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.

Rules for every shortcut
RuleWhat 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 pathA shortcut speeds up something people can also click. Nothing is keyboard-only, and nothing is pointer-only.
Keys are shown where they're usedIn 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 animateThe command menu opens and closes instantly, and J and K moves are instant. People repeat these dozens of times an hour.

The command menu#

⌘K from anywhere opens one palette that finds records, jumps to pages and runs actions, across all four apps. It's the fastest way to anything.

The command menu

Type to filter, use the arrow keys and Enter. Open as the palette shows it the way the shell does: centered high, no motion.

↑↓to move↵to openescto close
import { Button } from "@oration/canon/components/button";import {  Command,  CommandEmpty,  CommandGroup,  CommandInput,  CommandItem,  CommandList,  CommandSeparator,  CommandShortcut,} from "@oration/canon/components/command";import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@oration/canon/components/dialog";import { Kbd } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { BuildingIcon, FileTextIcon, SparklesIcon, WalletIcon } from "lucide-react";import * as React from "react";export function CommandMenu() {    const [open, setOpen] = React.useState(false);    const run = (title: string) => {        setOpen(false);        toast.add({ title });    };    const menu = (        <Command            loop            className="rounded-none! bg-transparent p-0 [&_[data-slot=command-input-wrapper]]:border-b [&_[data-slot=command-input-wrapper]]:border-border [&_[data-slot=command-input-wrapper]]:p-1.5 [&_[data-slot=input-group]]:h-10! [&_[data-slot=input-group]]:border-0! [&_[data-slot=input-group]]:bg-transparent!"        >            <CommandInput placeholder="Search records, pages and actions" />            <CommandList className="max-h-72 p-1.5">                <CommandEmpty className="py-10 text-center text-sm text-muted-foreground">                    No matches. Try a supplier or an invoice number.                </CommandEmpty>                <CommandGroup heading="Actions">                    <CommandItem onSelect={() => run("Copilot opened")}>                        <SparklesIcon aria-hidden="true" />                        Ask Copilot                        <CommandShortcut>⌘J</CommandShortcut>                    </CommandItem>                    <CommandItem onSelect={() => run("New payment run")}>                        <WalletIcon aria-hidden="true" />                        Create a payment run                    </CommandItem>                </CommandGroup>                <CommandSeparator className="my-1" />                <CommandGroup heading="Suppliers">                    <CommandItem                        onSelect={() => run("Opened Northwind Freight")}                    >                        <BuildingIcon aria-hidden="true" />                        Northwind Freight                    </CommandItem>                    <CommandItem                        onSelect={() => run("Opened Halcyon Packaging")}                    >                        <BuildingIcon aria-hidden="true" />                        Halcyon Packaging                    </CommandItem>                </CommandGroup>                <CommandSeparator className="my-1" />                <CommandGroup heading="Invoices">                    <CommandItem onSelect={() => run("Opened INV-20431")}>                        <FileTextIcon aria-hidden="true" />                        <span className="font-mono text-xs">INV-20431</span>                        <span className="text-muted-foreground">                            Halcyon Packaging                        </span>                    </CommandItem>                </CommandGroup>            </CommandList>            <div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground">                <span className="inline-flex items-center gap-1">                    <Kbd>↑</Kbd>                    <Kbd>↓</Kbd>                    to move                </span>                <span className="inline-flex items-center gap-1">                    <Kbd>↵</Kbd>                    to open                </span>                <span className="inline-flex items-center gap-1">                    <Kbd>esc</Kbd>                    to close                </span>            </div>        </Command>    );    return (        <div className="flex w-full max-w-xl flex-col items-start gap-4 text-left">            <div className="w-full overflow-hidden rounded-xl bg-popover shadow-popover">                {menu}            </div>            <Button variant="outline" onClick={() => setOpen(true)}>                Open as the palette            </Button>            <Dialog open={open} onOpenChange={setOpen}>                <DialogContent                    data-no-motion=""                    showCloseButton={false}                    className="top-[16%] max-w-[calc(100%-2rem)] translate-y-0 gap-0 overflow-hidden rounded-xl p-0 sm:max-w-xl"                >                    <DialogTitle className="sr-only">Command menu</DialogTitle>                    <DialogDescription className="sr-only">                        Search records, jump to a page or run an action.                    </DialogDescription>                    {menu}                </DialogContent>            </Dialog>        </div>    );}
  • Compose Dialog and Command as the shell does rather than using CommandDialog: data-no-motion on DialogContent, top-[16%], sm:max-w-xl and no close button.
  • Groups in this order: actions, apps, pages in the current app, then records. Group headings are sentence case: Suppliers, Invoices.
  • The footer teaches the palette's own keys with Kbd: ↑ ↓ to move, ↵ to open, esc to close.
  • The empty state suggests what to type: No matches. Try a supplier or an invoice number.

Global shortcuts#

Read from the shell's shortcuts dialog, which opens on ? anywhere outside a text field.

General#

Keyboard interactions
KeysAction
⌘KOpen command menu
⌘JAsk Copilot
⌘1–4Switch app
G1–4Switch app, alternative
⌘BToggle 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.

G then a letter, per app
AppKeys after G
CRMHHomeIInboxTTasksCCompaniesPPeopleDDealsSSequencesWWorkflowsAAgentsMMeetingsRReports
Agents PlatformHHomeRReportsBOverviewAAgentsFFlowsKKnowledge baseYHistory
Contact CenterWWorkspaceQQueuesCCustomersTTeam
TicketingUUnassignedMMy 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.

Keyboard interactions
KeysAction
JKNext and previous item
↵Open selected item
AApprove in Needs your call
SSkip in Needs your call
EMark inbox item done
⌘SSave from the save bar while a form has unsaved changes.
EscClose 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

  • 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.

Change the address, then press ⌘S while the field has focus.

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>    );}
Where keys appear
PlaceHow
TooltipLabel, then a KbdGroup. Kbd recolors itself on Graphite Ink.
ButtonThe 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 itemThe menu's own right-aligned DropdownMenuShortcut or CommandShortcut slot, in Slate Meta.
Hint row12px Slate Meta words with Kbd between them, beside the list they drive, hidden below 768px.
Shortcuts dialogEvery key the current app answers to, grouped, on ?.

The Machine Mono Rule

Geist Mono is only for strings a machine produced or will parse: record and run IDs, API keys, tokens, codes, DNS records, template variables, E.164 phone numbers, timecodes. Figures, labels, keyboard keys and headings stay in Geist Sans.
Ask Copilot⌘J
Do. Draw keys with Kbd in Geist Sans, after the action's name.
Ask Copilotcmd+j
Don't. Set keys in Geist Mono or as inline code. People press keys; machines don't produce them.

The 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

Motion uses 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:

apps/web/src/components/shell/global-keys.tsx
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.

Where focus goes
WhenFocus goes to
A dialog or sheet opensIts first field, or Cancel in a destructive confirmation.
A dialog, sheet, menu or the command menu closesThe control that opened it. Base UI returns it.
The active list item is approved, skipped or archivedThe next item, or the previous one at the end of the list.
An approval card resolvesIts Undo button, so the decision can be reversed straight away.
A page intro is dismissedA screen-reader note where the intro was.
An inline edit commits with Enter or cancels with EscThe text it edited, now in read mode.
A toast with Undo appearsStays 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-keyshortcuts carry the same information to assistive tech.

Components#

The parts this pattern is built from.