← Back to blog

A Command Palette and Keyboard-First Navigation for Trading UIs

Why traders live on the keyboard, and how to build a fast command palette with cmdk for switching markets, opening panels, and firing actions.

Oliver Benns
Oliver Benns

Software engineer · Creator of Hedge UI


Watch a professional trader for ten minutes and you will notice their hands barely leave the keyboard. The mouse is for charts and the occasional drag; everything else - switching from BTC to ETH, pulling up the order book, flattening a position - happens through muscle memory and shortcuts. A UI that forces them to hunt through menus with a cursor is a UI that gets replaced, because in fast markets the cost of a slow interaction is measured in basis points, not milliseconds of perceived snappiness.

The trouble is that you cannot ship a dedicated keybinding for every action without turning the app into an unlearnable mess of modifiers. The answer that desktop software, IDEs, and the better web apps have converged on is the command palette: a single overlay, opened with one chord, that exposes every navigable destination and every action behind a fuzzy search box. It is discoverable for newcomers and lightning fast for experts. This post walks through building one for a crypto trading interface with cmdk, and how it slots into the architecture the Hedge UI demo already uses.

Why a palette beats a wall of shortcuts

Global shortcuts and a command palette are not rivals; they are layers. The palette is the floor: it guarantees that anything the app can do is reachable from one entry point without memorising anything. Dedicated chords are the ceiling for the dozen actions a given trader repeats hundreds of times a day. You build the palette first because it is the cheaper, higher-leverage layer, and because it doubles as the place where you advertise the chords once they exist.

A trading UI has three broad categories of thing a palette needs to surface, and keeping them distinct in your head makes the implementation fall out naturally:

CategoryExamplesSource of truth
NavigationSwitch market to ETHUSDT, jump to a favouriteuseProductOptions(), FavoritesProvider
SurfacesOpen Order Book, open Market Chart, close a panelThe panel config registry in src/app.tsx
ActionsToggle theme, save layout, cancel all ordersVarious providers (ThemeProvider, etc.)

Navigation changes what you are looking at, surfaces change which panels are visible, and actions do something. The same overlay handles all three, but grouping them keeps the list scannable.

What the demo already ships

Before writing a line, it is worth knowing the demo is not starting from zero. The shadcn command wrapper is already vendored at src/components/ui/command.tsx, built on top of cmdk 1.1.1, and it exports the full set of primitives: Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandSeparator, CommandShortcut, and a CommandDialog that wraps everything in a Radix dialog with a screen-reader-only title and description.

Today those primitives power the product selector in src/header/header-product-select.tsx, where Command lives inside a Popover rather than a dialog - a combobox, not a global palette. The CommandDialog wrapper is present but currently unused, which makes it the obvious foundation for the palette. So this is less "add a dependency" and more "wire the pieces that are already in the box". Worth being honest about scope: the demo does not yet register a global Cmd/Ctrl+K listener, so the hotkey code below is the pattern to add rather than something you will find already running.

The core component

A command palette is just a dialog containing a cmdk instance whose open state is controlled by a hotkey. Here is the skeletal version, before we feed it any real data:

import { useState } from "react"; import { CommandDialog, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem, CommandShortcut, } from "@/components/ui/command"; export const CommandPalette = () => { const [open, setOpen] = useState(false); return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Search markets, panels, actions..." /> <CommandList> <CommandEmpty>No results found.</CommandEmpty> {/* groups go here */} </CommandList> </CommandDialog> ); };

cmdk does the heavy lifting that you would otherwise hand-roll badly: it filters items as you type, manages a single "selected" item that arrow keys move through, fires onSelect on Enter, and keeps the active item scrolled into view. The filtering is fuzzy by default, so typing obk will still surface "Order Book", and typing eth will match the ETHUSDT market. You can override the scoring with a custom filter prop, but the default is good enough that the product combobox in the demo leans on it directly.

Wiring the global hotkey

The palette needs to open from anywhere, which means a keydown listener on document. The non-negotiable details are: intercept Cmd+K on macOS and Ctrl+K elsewhere, call preventDefault() so the browser does not trigger its own focus-the-address-bar behaviour, and - this is the part people forget - remove the listener on unmount.

import { useEffect } from "react"; export const useCommandHotkey = (toggle: () => void) => { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key.toLowerCase() === "k" && (e.metaKey || e.ctrlKey)) { e.preventDefault(); toggle(); } }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [toggle]); };

Two judgement calls live in here. First, register on document, not window, and not on a wrapper div - a wrapper would only fire when focus is inside it, defeating the "from anywhere" goal. Second, be wary of swallowing the chord while the user is mid-type in an input. Cmd+K is rarely bound by forms, so toggling unconditionally is usually fine, but for single-key chords like / you must bail out when document.activeElement is an input, textarea, or contenteditable. The palette is forgiving precisely because its trigger uses a modifier.

Drop the hook into a top-level component, ideally the same place the <CommandPalette /> is mounted, so the listener's lifetime matches the palette's:

export const CommandPalette = () => { const [open, setOpen] = useState(false); useCommandHotkey(() => setOpen((prev) => !prev)); // ... };

Feeding it real data

The interesting work is turning the app's state into a flat list of commands. Markets come from useProductOptions(), which filters the live Binance exchangeInfo symbols against the curated allow-list in src/features/product/product-options.ts - BTC, ETH, SOL, AAVE, PEPE and a couple of dozen more. Each becomes a navigation command that calls setProduct from the ProductProvider context:

import { useProduct } from "@/features/product/product-context"; import { useProductOptions } from "@/features/product/product-options"; import { ProductIcon } from "@/components/product-icon"; const MarketCommands = ({ onRun }: { onRun: () => void }) => { const { products } = useProductOptions(); const { setProduct } = useProduct(); return ( <CommandGroup heading="Markets"> {(products ?? []).map((product) => ( <CommandItem key={product.id} value={product.id} keywords={[product.symbol, product.base.code]} onSelect={() => { setProduct(product); onRun(); }} > <ProductIcon baseCode={product.base.code} /> <span>{product.symbol}</span> </CommandItem> ))} </CommandGroup> ); };

Note the keywords prop. cmdk matches on the item's value plus any keywords, so setting value to the stable product.id (BTCUSDT) while adding the human-friendly symbol and base.code as keywords means a search for "bitcoin"-adjacent terms still lands. The product combobox in the demo already does exactly this with keywords={[option.symbol]}; the palette just widens the net.

Panels are even cleaner, because the demo already keeps a registry. The config object in src/app.tsx maps each panel id - market-trades, order-form, order-book, favorites, balances, price, volume, open-orders, market-chart - to a component and a display name. That object is your command source; iterate its entries and call addPanel from the active-layout context:

import { useActiveLayout } from "@/layout/layout-active-context"; const PanelCommands = ({ onRun }: { onRun: () => void }) => { const { config, addPanel, model } = useActiveLayout(); return ( <CommandGroup heading="Panels"> {Object.entries(config).map(([id, panel]) => ( <CommandItem key={id} value={`panel:${id}`} keywords={[panel.name]} onSelect={() => { addPanel(id, model.getActiveTabset()?.getId() ?? ""); onRun(); }} > {panel.name} </CommandItem> ))} </CommandGroup> ); };

This is the payoff of a registry-driven layout. Because the panels are data rather than a hard-coded switch statement, the palette stays in sync for free: add a tenth panel to config and it appears in the command list without touching the palette at all. The mechanics of how that layout slots panels into tabsets are covered in the writeup on building resizable, dockable panel layouts with FlexLayout.

Grouping markets, panels, and actions

With the data sources in place, the palette body is just three groups stacked inside CommandList. Actions are the smallest set and the most app-specific. A theme toggle reads naturally from the ThemeProvider, whose updateTheme flips the class on document.documentElement between light and dark:

const ActionCommands = ({ onRun }: { onRun: () => void }) => { const { theme, updateTheme } = useTheme(); return ( <CommandGroup heading="Actions"> <CommandItem value="toggle-theme" keywords={["dark", "light", "appearance"]} onSelect={() => { updateTheme(theme === "dark" ? "light" : "dark"); onRun(); }} > Toggle theme <CommandShortcut>⌘ T</CommandShortcut> </CommandItem> </CommandGroup> ); };

The demo defaults to and is built around dark mode, so in practice this action is more of a demonstration than a daily-use control, but the wiring is identical for any boolean or enum action. The reasoning behind a trading UI being dark-first, and the contrast pitfalls that come with it, is its own topic in designing dark mode for trading interfaces.

Favourites deserve their own group when the user has any. The FavoritesProvider exposes a favorites array of product ids plus isFavorite, so you can render a "Favourites" group above "Markets" and let traders jump to their watch-list with a couple of keystrokes - the same provider that powers the favourites panel, reused as a navigation shortcut. The full assembled list looks like this:

export const CommandPalette = () => { const [open, setOpen] = useState(false); useCommandHotkey(() => setOpen((prev) => !prev)); const close = () => setOpen(false); return ( <CommandDialog open={open} onOpenChange={setOpen}> <CommandInput placeholder="Search markets, panels, actions..." /> <CommandList> <CommandEmpty>No results found.</CommandEmpty> <FavoriteCommands onRun={close} /> <MarketCommands onRun={close} /> <PanelCommands onRun={close} /> <ActionCommands onRun={close} /> </CommandList> </CommandDialog> ); };

Each group's onRun closes the dialog after the action fires. For destructive actions like "cancel all orders" you would route through a confirmation rather than closing immediately, and a successful submit is a natural place for a toast confirmation - the demo already uses sonner for exactly this on order submission.

Accessibility is most of the value

A keyboard-first feature that is not accessible is a contradiction. The good news is that CommandDialog builds on the Radix dialog, so focus trapping, the Escape-to-close handler, and the inert background come for free. The wrapper in command.tsx also renders a DialogTitle and DialogDescription inside an sr-only header, which is what stops screen readers announcing an anonymous dialog - skip those and assistive tech has nothing to read out.

Beyond what the primitives hand you, three things are on you:

  • A visible focus ring. The active CommandItem is styled via data-[selected=true], which cmdk toggles as you arrow through the list. Keep that style high-contrast; a faint highlight is useless to a keyboard user who has no cursor to anchor on.
  • Roving focus, not real focus. Items are not individually focusable DOM nodes. cmdk keeps DOM focus on the input and moves a virtual selection, which is correct ARIA combobox behaviour. Do not try to .focus() the items yourself.
  • Announce the result. When the palette switches markets or opens a panel, the visual change may be off-screen for someone using a screen reader. A short aria-live toast closes that loop.

Trading interfaces have specific obligations around contrast, focus order, and not relying on colour alone to convey state, all of which the palette has to honour too. I went deep on those in the post on accessibility in trading UIs; the palette is one of the easiest places to get them right because Radix has done the structural work.

Keeping it fast with long lists

Three dozen markets is nothing, but the moment you let the list grow - every order, every alert, every historical fill as a navigable command - rendering hundreds of DOM nodes on every keystroke becomes the bottleneck. cmdk re-runs its filter on each input change and re-renders the matching items, so the cost scales with how many items survive the filter, not how many exist.

Two levers, in order of preference. First, cap what you render before filtering even runs. A palette is for finding things, not browsing them; showing the top 50 markets and relying on search for the long tail is both faster and better UX than dumping everything. Second, if you genuinely need a long always-visible list, virtualise it so only the visible rows mount. The product combobox in the demo takes a lighter version of this approach, wrapping its CommandList in a fixed-height ScrollArea (h-48) so the overflow is contained even though every item is still in the DOM.

A few habits keep the per-keystroke work cheap:

// Memoise the derived command list so it is not rebuilt on every render. const marketCommands = useMemo( () => (products ?? []).map(toCommand), [products], );

Memoising the mapped arrays, keeping onSelect handlers stable, and avoiding heavy work inside the render path are the same disciplines that keep any high-frequency React surface responsive. If the palette ever feels laggy while typing, the cause is almost always an unmemoised list being rebuilt on each render - the broader toolkit for diagnosing that is in building performant React trading applications.

Discoverability: teaching the shortcuts

The palette is where chords get learned. The CommandShortcut primitive renders a muted, right-aligned hint inside an item, so every command that also has a dedicated keybinding should display it. A trader who opens the palette to find "Order Book" sees ⌘ B next to it, uses the palette twice, and on the third time reaches for the chord directly. That progression - palette as training wheels, chord as the destination - is the whole point.

A small "recent commands" group at the top compounds this. Persisting the last handful of run commands to localStorage and surfacing them first means the palette adapts to each trader's actual workflow rather than presenting the same alphabetical wall every time. The demo persists layout and selection state this way already, and the same approach extends cleanly to command history, as covered in persisting UI state to localStorage.

It is also worth seeding the empty state with intent. Rather than a bare "No results", an empty palette can show the three or four most common commands as a starting menu, which doubles as onboarding for someone who hit Cmd+K out of curiosity.

Where this fits, and what to build next

A command palette is one of those features that looks like polish and turns out to be load-bearing. It is the difference between a demo that screenshots well and an interface a trader will actually live in for eight hours. Because the Hedge UI demo already vendors the cmdk wrapper, drives its layout from a panel registry, and keeps markets, favourites, and theme behind clean context providers, the palette is mostly an exercise in connecting existing wires rather than building new machinery.

If you are adding one to your own trading UI, the order of operations that works:

  1. Mount CommandDialog with a global Cmd/Ctrl+K listener, and clean the listener up on unmount.
  2. Feed it your real data sources - markets, the panel registry, theme and other actions - grouped so the list stays scannable.
  3. Lean on Radix for focus trapping, Escape, and labelling, then add the visible focus ring and live-region announcements yourself.
  4. Cap or virtualise long lists, memoise the derived commands, and surface recent and shortcut-bearing commands to make the thing learnable.

Get those four right and you have the single most keyboard-friendly surface in the app. For the wider set of decisions that separate a credible trading interface from a toy, the rundown of what trading UI projects get wrong is a good companion to this one.

Kickstart Your Trading Application

Hedge UI is a React starter kit with production-ready trading components, real-time data handling, and customisable layouts — so you can ship faster.

Get Hedge UI