# Command Menu

> A Ctrl/Cmd+K style command list with live filtering, arrow-key navigation, groups and shortcut hints.

- Kind: Element
- Page: https://ownlyplans.com/bhaskar/command-menu/
- Licence: MIT (do anything, keep the notice)

## Install

```bash
npx shadcn@latest add https://ownlyplans.com/r/bhaskar/command-menu.json
```

## Code

`index.html`

```html
<div class="cm" role="dialog" aria-label="Command menu">
  <div class="cm-search"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
    <input id="cm-q" type="text" placeholder="Type a command or search..." autocomplete="off" role="combobox" aria-expanded="true" aria-controls="cm-list">
    <kbd>Esc</kbd></div>
  <ul id="cm-list" class="cm-list" role="listbox"></ul>
</div>
```

`styles.css`

```css
.cm{width:min(420px,100%);background:var(--stage);border:1px solid var(--line);border-radius:14px;box-shadow:0 18px 50px -20px rgba(0,0,0,.35);overflow:hidden;font-size:14px}
.cm-search{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid var(--line)}
.cm-search svg{width:18px;height:18px;fill:none;stroke:var(--muted);stroke-width:2;stroke-linecap:round;flex:none}
.cm-search input{flex:1;min-width:0;border:0;background:none;color:var(--ink);font:inherit;outline:none}
.cm-search input::placeholder{color:var(--muted)}
kbd{font:600 11px/1 ui-monospace,monospace;color:var(--muted);border:1px solid var(--line);border-radius:6px;padding:4px 6px}
.cm-list{list-style:none;margin:0;padding:6px;max-height:190px;overflow:auto}
.cm-group{padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
.cm-item{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:9px 10px;border-radius:9px;cursor:pointer;color:var(--ink)}
.cm-item[aria-selected=true]{background:color-mix(in srgb,var(--accent) 14%,transparent)}
.cm-item mark{background:none;color:var(--accent);font-weight:700}
.cm-item span:last-child{color:var(--muted);font-size:12px}
.cm-empty{padding:22px;text-align:center;color:var(--muted)}
```

`script.js`

```js
const items=[
 {g:'Navigation',t:'Go to dashboard',k:'G D'},{g:'Navigation',t:'Open settings',k:'G S'},{g:'Navigation',t:'View billing',k:'G B'},
 {g:'Actions',t:'Create new project',k:'N'},{g:'Actions',t:'Invite a teammate',k:'I'},{g:'Actions',t:'Copy page link',k:'C L'},
 {g:'Theme',t:'Switch to dark mode',k:'T D'},{g:'Theme',t:'Switch to light mode',k:'T L'}];
const q=document.getElementById('cm-q'),list=document.getElementById('cm-list');let shown=[],active=0;
const esc=s=>s.replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
function mark(t,s){if(!s)return esc(t);const i=t.toLowerCase().indexOf(s.toLowerCase());return i<0?esc(t):esc(t.slice(0,i))+'<mark>'+esc(t.slice(i,i+s.length))+'</mark>'+esc(t.slice(i+s.length))}
function render(){const s=q.value.trim();shown=items.filter(i=>i.t.toLowerCase().includes(s.toLowerCase()));active=Math.min(active,Math.max(shown.length-1,0));
 if(!shown.length){list.innerHTML='<li class="cm-empty">No results for "'+esc(s)+'"</li>';return}
 let html='',g='';shown.forEach((i,n)=>{if(i.g!==g){g=i.g;html+='<li class="cm-group" role="presentation">'+g+'</li>'}
 html+='<li class="cm-item" role="option" id="cm-'+n+'" data-n="'+n+'" aria-selected="'+(n===active)+'"><span>'+mark(i.t,s)+'</span><span>'+i.k+'</span></li>'});
 list.innerHTML=html;q.setAttribute('aria-activedescendant','cm-'+active);const a=document.getElementById('cm-'+active);a&&a.scrollIntoView({block:'nearest'})}
function run(n){const i=shown[n];if(!i)return;q.value='';q.placeholder='Ran: '+i.t;active=0;render()}
q.addEventListener('input',()=>{active=0;render()});
q.addEventListener('keydown',e=>{if(e.key==='ArrowDown'){e.preventDefault();active=(active+1)%Math.max(shown.length,1);render()}
 else if(e.key==='ArrowUp'){e.preventDefault();active=(active-1+shown.length)%Math.max(shown.length,1);render()}
 else if(e.key==='Enter'){run(active)}else if(e.key==='Escape'){q.value='';render()}});
list.addEventListener('mousemove',e=>{const li=e.target.closest('.cm-item');if(li&&+li.dataset.n!==active){active=+li.dataset.n;render()}});
list.addEventListener('click',e=>{const li=e.target.closest('.cm-item');li&&run(+li.dataset.n)});
render();
```

`command-menu.tsx`

```tsx
"use client";
import { useEffect, useMemo, useRef, useState } from "react";

export type Command = { group: string; label: string; shortcut?: string; run: () => void };

const css = `
.cm{width:min(420px,100%);background:#fff;border:1px solid #d8deeb;border-radius:14px;box-shadow:0 18px 50px -20px rgba(0,0,0,.35);overflow:hidden;font:14px/1.5 system-ui,sans-serif;color:#0b1020}
.cm-search{display:flex;align-items:center;gap:10px;padding:12px 14px;border-bottom:1px solid #d8deeb}
.cm-search input{flex:1;min-width:0;border:0;background:none;color:inherit;font:inherit;outline:none}
.cm-kbd{font:600 11px/1 ui-monospace,monospace;color:#5c627c;border:1px solid #d8deeb;border-radius:6px;padding:4px 6px}
.cm-list{list-style:none;margin:0;padding:6px;max-height:280px;overflow:auto}
.cm-group{padding:8px 10px 4px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#5c627c}
.cm-item{display:flex;justify-content:space-between;gap:12px;padding:9px 10px;border-radius:9px;cursor:pointer}
.cm-item[aria-selected=true]{background:rgba(22,89,245,.12)}
.cm-item mark{background:none;color:#1659f5;font-weight:700}
.cm-hint{color:#5c627c;font-size:12px}
.cm-empty{padding:22px;text-align:center;color:#5c627c}`;

function Highlight({ text, query }: { text: string; query: string }) {
  const i = query ? text.toLowerCase().indexOf(query.toLowerCase()) : -1;
  if (i < 0) return <>{text}</>;
  return <>{text.slice(0, i)}<mark>{text.slice(i, i + query.length)}</mark>{text.slice(i + query.length)}</>;
}

export function CommandMenu({ commands, placeholder = "Type a command or search..." }: { commands: Command[]; placeholder?: string }) {
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const listRef = useRef<HTMLUListElement>(null);
  const shown = useMemo(() => commands.filter((c) => c.label.toLowerCase().includes(query.trim().toLowerCase())), [commands, query]);

  useEffect(() => setActive(0), [query]);
  useEffect(() => {
    listRef.current?.querySelector(`[data-n="${active}"]`)?.scrollIntoView({ block: "nearest" });
  }, [active]);

  const run = (n: number) => { shown[n]?.run(); setQuery(""); };
  const onKey = (e: React.KeyboardEvent) => {
    const len = Math.max(shown.length, 1);
    if (e.key === "ArrowDown") { e.preventDefault(); setActive((a) => (a + 1) % len); }
    else if (e.key === "ArrowUp") { e.preventDefault(); setActive((a) => (a - 1 + len) % len); }
    else if (e.key === "Enter") run(active);
    else if (e.key === "Escape") setQuery("");
  };

  let group = "";
  return (
    <div className="cm" role="dialog" aria-label="Command menu">
      <style>{css}</style>
      <div className="cm-search">
        <input autoFocus value={query} onChange={(e) => setQuery(e.target.value)} onKeyDown={onKey} placeholder={placeholder}
          role="combobox" aria-expanded="true" aria-controls="cm-list" aria-activedescendant={`cm-${active}`} />
        <kbd className="cm-kbd">Esc</kbd>
      </div>
      <ul id="cm-list" className="cm-list" role="listbox" ref={listRef}>
        {!shown.length && <li className="cm-empty">No results for "{query}"</li>}
        {shown.map((c, n) => {
          const head = c.group !== group ? (group = c.group) : null;
          return (
            <li key={c.group + c.label} role="presentation">
              {head && <div className="cm-group">{head}</div>}
              <div id={`cm-${n}`} data-n={n} role="option" aria-selected={n === active} className="cm-item"
                onMouseMove={() => setActive(n)} onClick={() => run(n)}>
                <span><Highlight text={c.label} query={query.trim()} /></span>
                {c.shortcut && <span className="cm-hint">{c.shortcut}</span>}
              </div>
            </li>
          );
        })}
      </ul>
    </div>
  );
}
```

## About

The fastest way to give an app a power-user feel. Type to filter, move with the arrow keys, press Enter to run. Matches are highlighted, empty results get a friendly message, and the list uses proper listbox roles so screen readers follow along.

Wire each item's action to your router or app state. In the React version pass your own commands as a prop.

---
Also available as JSON: https://ownlyplans.com/bhaskar/command-menu/?format=json
MCP server: https://ownlyplans.com/mcp
