Component
command-menu
Demo
tokens.css,
base.css and command-menu.css. No scripts.
Source
Copy both files, or run npx nojsui add command-menu.
Browser support
Shipped in every major engine, but only recently — older versions need the fallback.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| <dialog> | widely | 37 | 79 | 98 | 15.4 | 37 | 98 | 15.4 |
| Invoker commands | newly | 135 | 135 | 144 | 26.2 | 135 | 144 | 26.2 |
| :has() | widely | 105 | 105 | 121 | 15.4 | 105 | 121 | 15.4 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| @starting-style | newly | 117 | 117 | 129 | 17.5 | 117 | 129 | 17.5 |
| transition-behavior | newly | 117 | 117 | 129 | 17.4 | 117 | 129 | 17.4 |
This is not a command palette
It has no type-to-filter and no Cmd-K. Both need JavaScript, and components here ship none. ADR 0010 records what was prototyped before settling on this.
The short version of that prototype:
- Native text filtering exists and cannot be connected to an action.
<input list>with a<datalist>really does narrow as you type, with no script. But a datalist option is a value, not a destination — nohref, and choosing one only fills the input. Turning that into navigation needs script or a server route. - A global hotkey is not addressable in CSS at all. The trigger has to be something visible you click or tab to.
What does work is filtering by category, so that is what this does. If you need a real palette, you need JavaScript — and you should use a palette library rather than bend this one.
Usage
<button type="button" command="show-modal" commandfor="menu">Open menu</button>
<dialog id="menu" class="sk-command-menu" aria-labelledby="menu-title">
<header class="sk-command-menu__header">
<h2 id="menu-title" class="sk-command-menu__title">Go to</h2>
<button type="button" class="sk-command-menu__close" command="close" commandfor="menu" aria-label="Close menu">
<span aria-hidden="true">×</span>
</button>
</header>
<fieldset class="sk-command-menu__filters">
<legend class="sk-command-menu__legend">Filter by category</legend>
<input class="sk-command-menu__radio" type="radio" name="menu-filter" id="menu-all" checked />
<label class="sk-command-menu__filter" for="menu-all">All</label>
<input class="sk-command-menu__radio" type="radio" name="menu-filter" id="menu-pages" />
<label class="sk-command-menu__filter" for="menu-pages">Pages</label>
</fieldset>
<ul class="sk-command-menu__list">
<li class="sk-command-menu__row" data-group="pages">
<a class="sk-command-menu__item" href="/components/">Components</a>
</li>
</ul>
</dialog>
Filters map to categories by position: the second radio shows
data-group="pages", the third shows data-group="actions". The first is
always “all”. Renaming a category means changing the data-group value and the
label, not the CSS.
How it works
The dialog does the modal work — inert background, focus containment, Esc,
focus return — exactly as in dialog. Verified in all
three engines.
The filters are a radio group, so they are one tab stop with arrow-key
selection, the same mechanism as tabs. See that
component’s README for what a screen reader announces and how arrow keys differ
between engines; it applies here too.
Filtering is one :has() per rule, never a chain — CLAUDE.md forbids
:has() state machines and this stays well inside that.
Hidden rows are display: none, so their links leave the tab order and the
accessibility tree instead of lingering as invisible tab stops. Verified: a link
in a filtered-out row cannot take focus.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. command-menu.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-command-menu-accent | --sk-color-accent | Selected filter chip background |
--sk-command-menu-on-accent | --sk-color-on-accent | Selected filter chip text |
--sk-command-menu-bg | --sk-color-surface-raised | Menu background |
--sk-command-menu-fg | --sk-color-text | Menu and item text |
--sk-command-menu-border-color | --sk-color-border | Menu border and the rules inside it |
--sk-command-menu-radius | --sk-radius-lg | Menu corner radius |
--sk-command-menu-shadow | --sk-shadow-lg | Menu elevation |
--sk-command-menu-inline-size | 32rem | Menu width before the viewport clamp |
--sk-command-menu-close-fg | --sk-color-text-muted | Close button glyph |
--sk-command-menu-close-bg-hover | --sk-color-surface | Close button background on hover |
--sk-command-menu-filter-fg | --sk-color-text-muted | Unselected filter chip text |
--sk-command-menu-item-radius | --sk-radius-md | Item corner radius |
--sk-command-menu-item-bg-hover | --sk-color-accent-subtle | Item background on hover |
--sk-command-menu-hint-fg | --sk-color-text-subtle | Trailing hint text on an item |
--sk-command-menu-button-bg | --sk-color-surface | Trigger background |
--sk-command-menu-button-bg-hover | --sk-color-surface-raised | Trigger background on hover |
--sk-command-menu-button-fg | --sk-color-text | Trigger text |
--sk-command-menu-button-border-color | --sk-color-border-strong | Trigger border |
--sk-command-menu-button-radius | --sk-radius-md | Trigger corner radius |
:root { --sk-command-menu-inline-size: 40rem; }
The accent pair is a contrast pair: change --sk-command-menu-accent and check
--sk-command-menu-on-accent still reads against it. Which rows a filter shows
is markup (data-group), not theme.
Keyboard contract
| Key | Behaviour |
|---|---|
Enter / Space on the trigger | Opens the menu; focus moves inside |
Arrow keys on the filters | Change category, which filters the list |
Tab | Moves through the visible links; filtered-out links are skipped |
Enter on a link | Follows it |
Esc | Closes and returns focus to the trigger |
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
- The dialog needs an accessible name —
aria-labelledbypointing at the title. - The
<legend>names the filter group. It is clipped rather than removed, so it stays in the accessibility tree. - The category hint beside each item is decoration — it repeats the
data-groupvalue. The link text alone must make sense. - Links, not buttons. Every row here navigates. If a row should do something instead, that is an action, and actions need script — which is the boundary this component sits on.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
<dialog> | widely | — |
| Invoker Commands | newly | The trigger does nothing. Same floor as dialog; see docs/polyfills.md for the opt-in consumer shim. |
:has() | widely | Category filters do nothing; every row stays visible. The list is still usable. |
@starting-style, allow-discrete | newly | No open/close animation. |