← Blog8 min read#keyboard shortcuts#react#ux

Keyboard Shortcut Display in React: ⌘K Badges, Legend, Cheatsheet

Build polished ⌘K badges, shortcut legends, and cheatsheet overlays in React - with accessible markup, Tailwind styling, and real keyboard event wiring.

mechanical keyboard with glowing keys on dark gradient background

Why Keyboard Shortcut Display Is a UX Problem Worth Solving

Most apps implement keyboard shortcuts. Almost none display them well. You wire up ⌘K to open the command palette, feel great about it, then realise 80% of your users have no idea it exists because you never told them. Discoverability is the whole game here - a shortcut that nobody knows about may as well not exist.

The problem compounds once you have more than a handful of bindings. Apps like Linear, Figma, and Notion each ship 50+ shortcuts across different contexts. Without a structured way to surface them - inline badge hints, a toggleable legend, or an ? cheatsheet overlay - users rely on word-of-mouth or documentation they will never read. In practice, most keyboard power users discover shortcuts by accident or by reading a blog post.

Honestly, the state of shortcut display in the React ecosystem in 2026 is still surprisingly patchy. Libraries handle the *listening* part fine (react-hotkeys-hook is excellent), but the *display* layer - the <kbd> badge you slap next to a menu item, the floating legend in the corner, the full cheatsheet modal - you are mostly rolling that yourself. This article shows you exactly how.

Worth noting: getting this right also has real accessibility value. Screen readers benefit from explicit <kbd> markup. Discoverability improvements help users with motor disabilities who prefer keyboard navigation over mouse interactions. It's one of those cases where good UX and accessibility point in the same direction.

The `<kbd>` Element and Why It Matters

Before you reach for a component, understand your HTML primitive. The <kbd> element semantically represents keyboard input - browsers and screen readers treat it differently from a <span>. When a screen reader hits <kbd>⌘K</kbd> it announces "command K" rather than gibberish symbols. That alone is worth using it over a generic wrapper.

Default browser styling of <kbd> is ugly (monospace, no visual treatment at all). But because it carries semantic meaning, you style it rather than replace it. The combination of correct semantics plus visual polish is the entire point of a KbdBadge component.

// KbdBadge.tsx - base component
import { ReactNode } from 'react';

interface KbdBadgeProps {
  children: ReactNode;
  className?: string;
}

export function KbdBadge({ children, className = '' }: KbdBadgeProps) {
  return (
    <kbd
      className={[
        // shape
        'inline-flex items-center justify-center',
        'rounded-md px-1.5 py-0.5',
        'min-w-[1.5rem]',
        // typography
        'font-mono text-[11px] font-semibold leading-none',
        // light mode
        'bg-white/90 text-gray-700',
        'border border-gray-300 border-b-[2px]',
        'shadow-sm',
        // dark mode
        'dark:bg-gray-800 dark:text-gray-200',
        'dark:border-gray-600 dark:border-b-gray-500',
        className,
      ].join(' ')}
    >
      {children}
    </kbd>
  );
}

That border-b-[2px] trick - giving the bottom border one extra pixel - mimics the physical keycap shadow that makes badges look three-dimensional. It's a detail that costs you nothing but reads as craftsmanship. Pair it with a subtle box-shadow: 0 1px 2px rgba(0,0,0,0.1) if you want to push the depth further. The box shadow generator is handy for dialling in the exact shadow values without guessing in code.

Quick aside: on macOS you want the real Unicode glyphs - - not the words "Cmd" or "Ctrl". On Windows/Linux you obviously want the text labels. We'll handle cross-platform rendering in the section on the shortcut registry below.

Building a Shortcut Registry and Cross-Platform Key Renderer

Hardcoding ⌘K into your badge is fine for one shortcut. It breaks the moment you have 20. What you actually want is a central registry - a typed map of action names to key combos - and a renderer that adapts the display to the user's OS. Write it once, use it everywhere.

// shortcutRegistry.ts
const isMac =
  typeof navigator !== 'undefined' &&
  /Mac|iPhone|iPad|iPod/.test(navigator.platform);

export type ShortcutDef = {
  keys: string[];      // raw keys: ['meta', 'k'], ['ctrl', 'shift', 'p']
  label: string;       // human label for the legend
  group?: string;      // 'Navigation', 'Editing', etc.
};

// Platform-aware glyph map
const KEY_GLYPH: Record<string, string> = {
  meta:    isMac ? '⌘' : 'Win',
  alt:     isMac ? '⌥' : 'Alt',
  shift:   isMac ? '⇧' : 'Shift',
  ctrl:    isMac ? '⌃' : 'Ctrl',
  enter:   '↵',
  escape:  'Esc',
  arrowup: '↑',
  arrowdown: '↓',
  arrowleft: '←',
  arrowright: '→',
  backspace: '⌫',
};

export function resolveKeys(keys: string[]): string[] {
  return keys.map(k => KEY_GLYPH[k.toLowerCase()] ?? k.toUpperCase());
}

// Central registry
export const SHORTCUTS: Record<string, ShortcutDef> = {
  commandPalette: {
    keys: ['meta', 'k'],
    label: 'Open command palette',
    group: 'Navigation',
  },
  search: {
    keys: ['meta', 'shift', 'f'],
    label: 'Search everywhere',
    group: 'Navigation',
  },
  newItem: {
    keys: ['meta', 'n'],
    label: 'New item',
    group: 'Editing',
  },
  save: {
    keys: ['meta', 's'],
    label: 'Save',
    group: 'Editing',
  },
  showHelp: {
    keys: ['shift', '/'],
    label: 'Show shortcuts',
    group: 'Navigation',
  },
};

Now your KbdBadge can pull from the registry instead of containing hardcoded strings. Build a thin ShortcutBadge wrapper that takes an action name and renders the composed keys:

// ShortcutBadge.tsx
import { SHORTCUTS, resolveKeys } from './shortcutRegistry';
import { KbdBadge } from './KbdBadge';

interface ShortcutBadgeProps {
  action: keyof typeof SHORTCUTS;
  className?: string;
}

export function ShortcutBadge({ action, className }: ShortcutBadgeProps) {
  const def = SHORTCUTS[action];
  if (!def) return null;

  const glyphs = resolveKeys(def.keys);

  return (
    <span className={`inline-flex items-center gap-0.5 ${className ?? ''}`}>
      {glyphs.map((g, i) => (
        <KbdBadge key={i}>{g}</KbdBadge>
      ))}
    </span>
  );
}

Usage in a menu item is now <ShortcutBadge action="commandPalette" /> - and if you ever rename the key combo, you update exactly one place. That's the entire value of the registry pattern. No hunting through JSX for hardcoded ⌘K strings.

The Inline Shortcut Hint: Wiring Badges to Menu Items and Tooltips

The most common placement is a right-aligned badge in a dropdown menu item. Linear does this, Figma does this, VS Code does this. It works because users encounter the shortcut at the exact moment they're performing the action via mouse - the two paths reinforce each other.

// MenuItem.tsx - with inline shortcut hint
import { ShortcutBadge } from './ShortcutBadge';
import { SHORTCUTS } from './shortcutRegistry';

interface MenuItemProps {
  label: string;
  action?: keyof typeof SHORTCUTS;
  onClick: () => void;
}

export function MenuItem({ label, action, onClick }: MenuItemProps) {
  return (
    <button
      onClick={onClick}
      className="flex w-full items-center justify-between
                 px-3 py-2 text-sm rounded-md
                 hover:bg-gray-100 dark:hover:bg-gray-800
                 focus:outline-none focus-visible:ring-2
                 focus-visible:ring-violet-500"
    >
      <span>{label}</span>
      {action && (
        <ShortcutBadge
          action={action}
          className="ml-4 opacity-60 group-hover:opacity-100"
        />
      )}
    </button>
  );
}

The opacity-60 treatment is intentional - you want the badge to be visible but not competing with the label for attention. On hover or focus it can lift to full opacity. That 40% opacity delta is a detail most developers skip, and it's one of the things that separates polished UIs from functional ones.

For tooltip use, add the shortcut as a suffix inside the tooltip content rather than in a separate badge: "Save file ⌘S". Keep it in the same <kbd> markup even inside tooltip strings, and make sure your tooltip component renders HTML if you're including actual <kbd> tags. If it only renders plain text, just use the glyph strings directly - no markup needed in that context.

Look, you don't need to put shortcut badges everywhere. Only add them where the shortcut is likely to be used frequently, where discovering it saves real time. Adding ⌘C next to every copyable field is visual noise. Prioritise power-user actions: palette, search, save, undo, navigation between items.

Building the Shortcut Legend and Cheatsheet Overlay

The legend is a persistent, compact panel - usually bottom-right, maybe 240px wide - listing the top 5-8 shortcuts for the current view. The cheatsheet overlay (? key or a button) shows everything, grouped by category, in a full modal. Both pull from the same registry; only the layout differs.

// ShortcutLegend.tsx - compact floating legend
import { SHORTCUTS, resolveKeys } from './shortcutRegistry';
import { KbdBadge } from './KbdBadge';

const LEGEND_ACTIONS = [
  'commandPalette',
  'search',
  'newItem',
  'save',
  'showHelp',
] as const;

export function ShortcutLegend() {
  return (
    <aside
      aria-label="Keyboard shortcuts"
      className="fixed bottom-4 right-4 z-40
                 w-56 rounded-xl p-3
                 bg-white/80 dark:bg-gray-900/80
                 backdrop-blur-md
                 border border-gray-200 dark:border-gray-700
                 shadow-xl text-xs"
    >
      <p className="font-semibold text-gray-500 dark:text-gray-400 mb-2 uppercase tracking-wider">
        Shortcuts
      </p>
      <ul className="space-y-1.5">
        {LEGEND_ACTIONS.map(action => {
          const def = SHORTCUTS[action];
          const glyphs = resolveKeys(def.keys);
          return (
            <li key={action} className="flex items-center justify-between">
              <span className="text-gray-700 dark:text-gray-300">
                {def.label}
              </span>
              <span className="flex gap-0.5">
                {glyphs.map((g, i) => (
                  <KbdBadge key={i}>{g}</KbdBadge>
                ))}
              </span>
            </li>
          );
        })}
      </ul>
    </aside>
  );
}

The backdrop-blur-md on the legend panel is deliberate - it's a floating glass surface, and it should feel like it's floating above the content rather than sitting on top of it. If your project uses glassmorphism components, you can swap in the GlassCard wrapper here and get the aesthetic consistency for free.

The full cheatsheet overlay is the same idea scaled up. Group your SHORTCUTS entries by their group property, render each group as a section with a heading, and present the whole thing in a modal that traps focus correctly. Open it on ? (shift+/ in most keyboards) and close it on Escape. Here's the grouping logic:

// groupBy utility
function groupBy<T>(arr: T[], key: (item: T) => string): Record<string, T[]> {
  return arr.reduce((acc, item) => {
    const k = key(item);
    acc[k] = acc[k] ?? [];
    acc[k].push(item);
    return acc;
  }, {} as Record<string, T[]>);
}

// Inside CheatsheetModal
const grouped = groupBy(
  Object.entries(SHORTCUTS),
  ([, def]) => def.group ?? 'General'
);
// Render grouped entries in sections...

One more thing - wire the ? shortcut itself using react-hotkeys-hook. It's the cleanest shortcut listener for React in 2026, with SSR safety and proper cleanup built in: useHotkeys('shift+/', () => setOpen(true)). Don't roll your own keydown listeners if you can avoid it; the edge cases (input fields, IME composition, modifiers) are not worth the debugging time.

Accessibility: `aria-keyshortcuts` and Screen Reader Announcements

The aria-keyshortcuts attribute is the semantic bridge between your visual badges and assistive technology. Add it to the interactive element that the shortcut activates - the button, the menu trigger, the input - and screen readers will announce the shortcut as part of the element's description.

// Correct usage of aria-keyshortcuts
<button
  onClick={openCommandPalette}
  aria-label="Command palette"
  aria-keyshortcuts="Meta+K"
>
  Search
</button>

The value format is strict: modifier names are capitalised (Meta, Control, Shift, Alt), followed by the key separated by +. No spaces. Browsers don't yet do anything automatic with this attribute (as of 2026), but screen readers like NVDA 2024.1+ and VoiceOver on macOS 15 do announce it. Worth adding even if the visual polish benefit is zero.

That said, aria-keyshortcuts is not a replacement for <kbd> semantics in your legend and cheatsheet. Those two things serve different users in different contexts. The attribute helps screen reader users who can't see your legend. The <kbd> element helps them when they navigate to a cheatsheet or tooltip that contains shortcut documentation.

For users who have prefers-reduced-motion set, there's nothing specific to shortcuts - but if your cheatsheet modal animates in with a scale transform, wrap it in a motion guard. And if your legend has an auto-hide animation based on idle state, disable that for users who've explicitly asked for reduced motion. The wcag-accessibility-guide covers this decision framework in depth.

Styling Shortcut Badges with Tailwind Variants and Dark Mode

The KbdBadge component above handles dark mode with Tailwind's dark: prefix, but you often want style variants tied to context - a badge inside a neobrutalism UI should look hard-edged and bold, not soft and translucent. A badge in a glassmorphism panel should feel frosted. Make variants first-class:

// Extended KbdBadge with variants
type KbdVariant = 'default' | 'ghost' | 'neo' | 'glass';

const VARIANT_CLASSES: Record<KbdVariant, string> = {
  default:
    'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 border-b-[2px] shadow-sm',
  ghost:
    'bg-transparent border border-current opacity-60 hover:opacity-100',
  neo:
    'bg-yellow-300 text-black border-2 border-black shadow-[2px_2px_0px_black]',
  glass:
    'bg-white/10 backdrop-blur-sm border border-white/20 text-white shadow-none',
};

export function KbdBadge({
  children,
  variant = 'default',
  className = '',
}: KbdBadgeProps & { variant?: KbdVariant }) {
  return (
    <kbd
      className={[
        'inline-flex items-center justify-center',
        'rounded-md px-1.5 py-0.5 min-w-[1.5rem]',
        'font-mono text-[11px] font-semibold leading-none',
        VARIANT_CLASSES[variant],
        className,
      ].join(' ')}
    >
      {children}
    </kbd>
  );
}

The neo variant mimics the hard offset-shadow style you'll find in neobrutalism UIs - that shadow-[2px_2px_0px_black] Tailwind arbitrary value is exactly what makes it feel right. The glass variant plugs directly into glassmorphism layouts. One component, four personalities.

In practice, you'd propagate the variant via React context rather than threading it manually through every component tree. Create a UIStyleContext that holds the current visual style, and let KbdBadge read from it. That way switching from default to glass at the layout level cascades down automatically.

One last detail: size your badges in em-relative units where possible. Text-level content (menu items, tooltips, button labels) should have badges that scale with the surrounding font size. Fixed px values for badge padding look fine at 14px body text but get out of proportion in headings or compact 12px UI. The px-1.5 py-0.5 Tailwind classes are 0.375rem and 0.125rem respectively - close enough to em-relative for most use cases, but worth checking at both ends of your type scale. You can preview the full styling stack in Empire UI's component browser where badge components come pre-configured for every visual theme.

FAQ

Should I use `<kbd>` or `<span>` for keyboard shortcut badges?

Use <kbd> - it carries semantic meaning that screen readers interpret correctly, announcing key names rather than raw symbols. Style it with CSS or Tailwind; don't replace it with a generic element just for styling convenience.

How do I handle cross-platform shortcut display (Mac vs Windows)?

Check navigator.platform at runtime and map modifier keys to platform-specific glyphs - on Mac, Ctrl on Windows/Linux. Store your shortcuts as abstract key arrays like ['meta', 'k'] and resolve them to display strings through a platform-aware map.

What's the right `aria` attribute for keyboard shortcuts?

aria-keyshortcuts goes on the element the shortcut activates. Use the format "Meta+K" with capitalised modifier names and no spaces. Screen readers like NVDA and VoiceOver announce it as part of the element description.

Is `react-hotkeys-hook` still the best option for listening to shortcuts in 2026?

Yes - it handles SSR safety, proper cleanup, and modifier key edge cases out of the box. Rolling your own keydown listener will work but you'll spend hours debugging IME composition and input-field edge cases that the library already solves.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

React UI Components Complete Reference: 60+ Patterns with CodeDropdown Menu in React: Accessible, Animated, Keyboard-ReadySearch Bar in React: Debounce, Autocomplete and Keyboard NavigationFocus Management in React: Trap, Return and Programmatic Focus