Showing Shortcut Hints in Extension UI

Teach users an extension's keyboard shortcuts without nagging: hints in buttons, tooltips and the action title, accessible kbd markup and aria-keyshortcuts, hiding hints for unassigned commands, and first-use discovery.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

Keyboard shortcuts are the fastest way to use an extension, and most users never learn them. The ones who do usually learned from a hint shown at the right moment: a small Alt+Shift+S next to the Save button, a tooltip on the toolbar icon, a one-time “Next time, press…” after the third mouse click. Hints that are wrong — because the user rebound the shortcut or it was never assigned — teach the wrong thing and erode trust. This guide shows where to put hints, how to render them accessibly, how to keep them accurate from commands.getAll, and how to nudge without nagging. It belongs to keyboard shortcuts and commands.

Where hints belong

Hints work best where the user is already doing the thing the shortcut does. In the popup or side panel, put the shortcut inline next to the button it duplicates, in a muted style. On the toolbar icon, include it in the action title — the browser shows it as the tooltip on hover. In menus and command palettes, align shortcuts to the right as native menus do. In onboarding, show the one or two most valuable shortcuts, not all of them. Each hint must come from the current configuration, which chrome.commands.getAll returns, and must disappear when the command is unassigned rather than show a stale suggestion.

Hint placements, from passive to activeShortcut hints placed in the action tooltip, beside popup buttons, in a command palette, and as a one-time nudge after repeated mouse use, all fed by commands.getAll.commands.getAll()current bindingsAction titlehover tooltipBeside buttonspopup, panelmore prominentCommand paletteright-aligned keysOnboardingtop 1–2 shortcutsOne-time nudgeafter 3 mouse uses
Passive hints always; an active nudge at most once per command.

Step-by-step: accurate, accessible hints

1. Load bindings into a lookup

1// hints.js
2let bindings = new Map();
3export async function loadBindings() {
4  bindings = new Map((await chrome.commands.getAll()).filter((c) => c.shortcut).map((c) => [c.name, c.shortcut]));
5  return bindings;
6}
7export const shortcutFor = (name) => bindings.get(name) ?? null;

Execution context: extension pages and the service worker. Only assigned commands enter the map, so shortcutFor returns null for anything the user cannot actually press. Call loadBindings() when each page opens; popups are short-lived, so this is effectively always fresh. See listing configured shortcuts with commands.getAll.

2. Render hints with kbd and aria-keyshortcuts

 1export function decorateButton(button, commandName) {
 2  const sc = shortcutFor(commandName);
 3  button.querySelector(".hint")?.remove();
 4  button.removeAttribute("aria-keyshortcuts");
 5  if (!sc) return;
 6  const hint = document.createElement("span");
 7  hint.className = "hint";
 8  hint.setAttribute("aria-hidden", "true");
 9  for (const part of splitShortcut(sc)) hint.append(Object.assign(document.createElement("kbd"), { textContent: part }));
10  button.append(hint);
11  button.setAttribute("aria-keyshortcuts", toAriaKeys(sc));     // "Control+Shift+S"
12}

Execution context: the popup or side panel. The visible hint is aria-hidden to avoid screen readers reading “Save Ctrl Shift S” as part of the button name; instead, aria-keyshortcuts exposes the shortcut in a structured way that assistive technology can announce as a hint. Its value uses full key names (Control, Shift, Alt, Meta) joined with +.

Hint surfaces and their constraintsAction tooltip, inline button hints, command palette and onboarding compared on visibility, how they stay accurate, and accessibility handling.SurfaceVisibilityKeeps accurate byAccessibilityAction tooltipOn hoversetTitle on startup + pop…Native tooltipInline button hintAlwaysgetAll on openaria-keyshortcutsCommand paletteWhen opengetAll on openListbox + descriptionOnboardingOncegetAll at renderText + kbd
Every surface reads from the same lookup.

3. Put the popup shortcut in the action title

1// sw.js
2async function refreshActionTitle() {
3  const [open] = (await chrome.commands.getAll()).filter((c) => c.name === "_execute_action");
4  const base = chrome.i18n.getMessage("extName");
5  await chrome.action.setTitle({ title: open?.shortcut ? `${base} (${open.shortcut})` : base });
6}
7chrome.runtime.onStartup.addListener(refreshActionTitle);
8chrome.runtime.onInstalled.addListener(refreshActionTitle);

Execution context: the service worker. The toolbar tooltip is the most-seen hint of all. Because there is no change event in Chrome, also refresh it when the popup opens (send a message from the popup) so a rebinding is reflected soon after. See setting the action title and tooltip.

4. Style hints to be present but quiet

1.hint { margin-inline-start: auto; display: inline-flex; gap: 2px; opacity: .7; font-size: .8em; }
2kbd { font: inherit; padding: 0 .35em; border: 1px solid currentColor; border-radius: 4px; line-height: 1.4; }
3@media (forced-colors: active) { kbd { border-color: ButtonText; } }

Execution context: extension page CSS. margin-inline-start: auto pushes the hint to the end of the button in both LTR and RTL layouts. Borders keep key chips visible in forced colours mode, as covered in respecting reduced motion and forced colors.

5. Nudge once after repeated mouse use

 1// popup.js
 2async function trackMouseUse(commandName) {
 3  const sc = shortcutFor(commandName);
 4  if (!sc) return;
 5  const key = `mouseUses:${commandName}`;
 6  const { [key]: n = 0, nudged = [] } = await chrome.storage.local.get([key, "nudged"]);
 7  await chrome.storage.local.set({ [key]: n + 1 });
 8  if (n + 1 >= 3 && !nudged.includes(commandName)) {
 9    showToast(chrome.i18n.getMessage("shortcutNudge", [sc]));     // "Tip: press $1 next time"
10    await chrome.storage.local.set({ nudged: [...nudged, commandName] });
11  }
12}

Execution context: the popup. A nudge after the third mouse use reaches users who repeat an action, at the moment they are doing it, and it never repeats. Counting locally keeps it private. Use a polite live region for the toast so screen reader users hear it without losing focus.

A one-time shortcut nudgeThe user clicks Save with the mouse three times across sessions; on the third, the popup shows a tip with the current shortcut and records that the nudge was shown, so it never appears again for that command.UserPopupstorage.localclick Save (3rd time)mouseUses:save-page = 3"Tip: press Alt+S next time"nudged += save-pagelater clicks → no tip
Right moment, right binding, only once.

6. Prompt for unassigned shortcuts instead of hiding them forever

In settings, show unassigned commands with a “Set a shortcut” link to the browser’s shortcut page. Elsewhere, simply omit the hint. Users who want shortcuts will find them in settings; users who don’t are not bothered.

7. Show in-page shortcuts separately

Shortcuts handled by a content script (keydown listeners on the page) are not in commands.getAll. Keep them in your own registry so they can be listed alongside commands, and make them configurable to avoid conflicts with sites. See handling shortcuts in content scripts.

8. Localise the modifier names

On macOS Chrome returns ⇧⌘S; on Windows Ctrl+Shift+S. If you construct hints yourself (for in-page shortcuts), use the same conventions per platform, and localise key names where appropriate — German keyboards label Ctrl as “Strg”. See Mac and Windows modifier keys in commands.

9. Offer a shortcut sheet

1document.addEventListener("keydown", (e) => {
2  if (e.key === "?" && !e.target.closest("input, textarea, [contenteditable]")) {
3    document.querySelector("#shortcut-sheet").showModal();
4  }
5});

Execution context: the popup or side panel. A ? key that opens a modal sheet listing every assigned command, plus in-page shortcuts from your own registry, gives keyboard users one place to learn everything. Ignore the key while the user is typing in a field so it never steals a literal question mark. Render the sheet from the same lookup as the inline hints so all surfaces agree.

Common mistakes

  • Static hint text. Wrong after rebinding or conflict.
  • Hints read as part of button names. Use aria-hidden plus aria-keyshortcuts.
  • Repeating nudges. Once per command is enough.
  • Showing hints for unassigned commands. The key does nothing.
  • Mixing in-page and browser shortcuts silently. List them separately.

Cross-browser variation

  • Chrome / Edge: getAll returns platform-formatted strings; no change event, so refresh on UI open.
  • Firefox: same API plus commands.onChanged, which lets you refresh hints immediately.
  • Safari: test the strings returned; keep hints optional so missing data simply hides them.

Verification

  1. Rebind a shortcut and confirm the popup button hint and toolbar tooltip update.
  2. Clear a shortcut and confirm its hint disappears everywhere.
  3. With a screen reader, focus the Save button and confirm the name is “Save” with the shortcut announced as a hint.
  4. Click Save three times and confirm the nudge appears once only.

FAQ

Does aria-keyshortcuts make the shortcut work?

No. It only describes it; the command still comes from the manifest or your key handler.

Should hints show on touch devices?

Hide them when no keyboard is likely — for example, under (hover: none) and (pointer: coarse) in CSS.

Can I show all shortcuts with “?”

Yes — a ? key handler in the popup that opens a shortcut sheet is a common, discoverable pattern.

Should hints be translated?

The key names returned by getAll are already localised by the browser on some platforms. Translate the surrounding copy (“Tip: press $1 next time”) through messages.json, and pass the shortcut as a placeholder so word order can change per language.

Other UI/UX Patterns & Interactive Components Resources