Give your site the Cmd+K menu users expect

Give your site the Cmd+K menu users expect

Infrastructure for Coding Agents

Zerops ZCP lets you deploy an AI-ready environment, connect agents like Claude Code or Codex, and build directly inside your live cloud workspace.
Start from a recipe, describe the change you want, and let the agent build, deploy, and verify it.

Try Zerops ZCP

Your app has fifteen pages and a sidebar that scrolls. The people using it every day already know where they want to go, they just have to click through two menus to get there. Linear and GitHub both fix this the same way: you hit Cmd+K and type a couple of letters instead of aiming at anything.

This is called a command palette, and it’s the kind of thing people install a library for. I shipped one on markodenic.tech with no dependencies at all: the <dialog> element does the modal part, so what’s left is a keydown listener and a filter you’ve written a hundred times.

The fix

html
<dialog id="palette">
  <input type="search" placeholder="Where to?" />
  <ul>
    <li><a href="/dashboard">Dashboard</a></li>
    <li><a href="/orders">Orders</a></li>
    <li><a href="/customers">Customers</a></li>
    <li><a href="/settings">Settings</a></li>
  </ul>
</dialog>
javascript
const palette = document.getElementById('palette');
const input = palette.querySelector('input');
const items = palette.querySelectorAll('li');

document.addEventListener('keydown', (e) => {
  if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
    e.preventDefault();
    input.value = '';
    items.forEach((item) => (item.hidden = false));
    palette.showModal();
    input.focus();
  }
});

input.addEventListener('input', () => {
  const query = input.value.toLowerCase();
  items.forEach((item) => {
    item.hidden = !item.textContent.toLowerCase().includes(query);
  });
});

Press Cmd+K (or Ctrl+K on Windows), type a few letters, click the destination. That’s the whole feature.

<dialog> does the hard part

showModal() covers the parts you’d otherwise hand-roll:

  • Escape closes it: no listener needed.
  • Focus stays inside: tabbing can’t wander into the page behind the palette.
  • A real backdrop: style it with ::backdrop.
css
#palette::backdrop {
  background: rgb(0 0 0 / 0.4);
  backdrop-filter: blur(2px);
}

A modal dialog sits vertically centered by default, and a palette belongs near the top of the screen. It also arrives with its own border and padding, which will fight whatever you put inside it:

css
#palette {
  margin-top: 15vh;
  padding: 0;
  border: 0;
}

Setting only margin-top leaves the left and right margins on auto, so it stays horizontally centered.

Once you have more items than fit on screen, the results list gets its own scrollbar, and scrolling past the end of it starts scrolling the page behind the palette. Same fix as always:

css
#palette ul {
  max-height: 40vh;
  overflow-y: auto;
  overscroll-behavior: contain;
}

That’s scroll chaining and it bites every scrollable thing inside a modal.

Where this comes up

  • Admin panels: power users live in them all day and feel every extra click.
  • Docs sites: jumping straight to a page beats scanning a sidebar tree.
  • Any app past five pages: the point where a nav menu stops being enough on its own.

The values, explained

e.metaKey || e.ctrlKey covers Mac and Windows in one condition. The preventDefault() is not optional: Ctrl+K focuses the address bar in Chrome and opens quick search in Firefox, so without it the browser takes the keystroke.

e.key reports the character that was typed, so with Caps Lock on you get 'K' and a plain === 'k' check silently stops working. Hence toLowerCase().

The filter is the same includes() pass you’d write for any search box, pointed at links instead of FAQ items. It runs against a list that’s already in the DOM, so it stays instant and needs no debouncing. The moment you point the palette at an API instead, that changes.

Start with plain navigation. Actions like “Toggle dark mode” or “Copy invite link” are just more <li> items with a click handler, add those once navigation feels solid.

One line worth adding, especially in a single-page app where clicking a link doesn’t reload anything:

javascript
palette.addEventListener('click', (e) => {
  if (e.target.closest('a')) palette.close();
});

One listener on the dialog covers every item, including the ones you add next year. That’s event delegation doing the work.

And phones have no Cmd key, so put a visible button somewhere in your header that calls the same showModal().

Why this matters

  • Nothing to install: no dependency, no bundle cost, no migration when it hits version 3.
  • Accessible without effort: focus handling and Escape are the parts of a modal people get wrong, and the browser does both.
  • Cheap to extend: every page you add later is one more <li>.

Add it tonight with your five most-visited pages and nothing else. Actions can wait, most of the value is in those five links.

P.S. I implemented this feature on this website. Give it a try. CMD + K / CTRL + K

Happy coding!

Marko

Rate this issue

Your ratings shape which issues I recommend to you.