Skip to content

Component

command-menu

Demo

Live demo, isolated in an iframe with only tokens.css, base.css and command-menu.css. No scripts.

Source

Copy both files, or run npx nojsui add command-menu.

Source for command-menu
<!-- .sk-command-menu — a modal list of destinations, filterable by category.

     Not a command palette: no type-to-filter and no global hotkey, both of
     which need JavaScript. ADR 0010 explains what was prototyped and why this
     is a menu instead.

     The filters are a radio group, so they are one tab stop with arrow-key
     selection — the same mechanism as .sk-tabs. -->
<button
  type="button"
  class="sk-command-menu__trigger"
  command="show-modal"
  commandfor="sk-command-menu-demo"
>
  Open menu
</button>

<dialog
  id="sk-command-menu-demo"
  class="sk-command-menu"
  aria-labelledby="sk-command-menu-demo-title"
>
  <header class="sk-command-menu__header">
    <h2 id="sk-command-menu-demo-title" class="sk-command-menu__title">Go to</h2>
    <button
      type="button"
      class="sk-command-menu__close"
      command="close"
      commandfor="sk-command-menu-demo"
      aria-label="Close menu"
    >
      <span aria-hidden="true">&times;</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="sk-command-menu-demo-filter"
      id="sk-command-menu-demo-all"
      checked
    />
    <label class="sk-command-menu__filter" for="sk-command-menu-demo-all">All</label>

    <input
      class="sk-command-menu__radio"
      type="radio"
      name="sk-command-menu-demo-filter"
      id="sk-command-menu-demo-pages"
    />
    <label class="sk-command-menu__filter" for="sk-command-menu-demo-pages">Pages</label>

    <input
      class="sk-command-menu__radio"
      type="radio"
      name="sk-command-menu-demo-filter"
      id="sk-command-menu-demo-actions"
    />
    <label class="sk-command-menu__filter" for="sk-command-menu-demo-actions">Actions</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
        <span class="sk-command-menu__hint">Pages</span>
      </a>
    </li>
    <li class="sk-command-menu__row" data-group="pages">
      <a class="sk-command-menu__item" href="#tokens">
        Design tokens
        <span class="sk-command-menu__hint">Pages</span>
      </a>
    </li>
    <li class="sk-command-menu__row" data-group="actions">
      <a class="sk-command-menu__item" href="#theme">
        Toggle theme
        <span class="sk-command-menu__hint">Actions</span>
      </a>
    </li>
    <li class="sk-command-menu__row" data-group="actions">
      <a class="sk-command-menu__item" href="#copy">
        Copy install command
        <span class="sk-command-menu__hint">Actions</span>
      </a>
    </li>
  </ul>
</dialog>
/* .sk-command-menu — a modal list of destinations, filtered by category.

   Deliberately not a command palette; ADR 0010 records what was prototyped and
   why. The dialog supplies modality, focus containment, Esc and focus return;
   the filters are a radio group, so they are one tab stop with arrow keys.

   Specs:
   - <dialog> ............... https://html.spec.whatwg.org/multipage/interactive-elements.html#the-dialog-element
   - invoker commands ....... https://html.spec.whatwg.org/multipage/form-elements.html#attr-button-command
   - :has() ................. https://drafts.csswg.org/selectors-4/#has-pseudo */

/* Public theme knobs (ADR 0011). Read, never declared, so the nearest
   declaration above the component wins. No single root here — the menu is a
   top-layer <dialog> and the trigger is its sibling — so both carry them. */
.sk-command-menu,
.sk-command-menu__trigger {
  --_accent: var(--sk-command-menu-accent, var(--sk-color-accent));
  --_on-accent: var(--sk-command-menu-on-accent, var(--sk-color-on-accent));
  --_bg: var(--sk-command-menu-bg, var(--sk-color-surface-raised));
  --_fg: var(--sk-command-menu-fg, var(--sk-color-text));
  --_border: var(--sk-command-menu-border-color, var(--sk-color-border));
  --_radius: var(--sk-command-menu-radius, var(--sk-radius-lg));
  --_shadow: var(--sk-command-menu-shadow, var(--sk-shadow-lg));
  --_inline-size: var(--sk-command-menu-inline-size, 32rem);
  --_close-fg: var(--sk-command-menu-close-fg, var(--sk-color-text-muted));
  --_close-bg-hover: var(--sk-command-menu-close-bg-hover, var(--sk-color-surface));
  --_filter-fg: var(--sk-command-menu-filter-fg, var(--sk-color-text-muted));
  --_item-radius: var(--sk-command-menu-item-radius, var(--sk-radius-md));
  --_item-bg-hover: var(--sk-command-menu-item-bg-hover, var(--sk-color-accent-subtle));
  --_hint-fg: var(--sk-command-menu-hint-fg, var(--sk-color-text-subtle));
  --_button-bg: var(--sk-command-menu-button-bg, var(--sk-color-surface));
  --_button-bg-hover: var(--sk-command-menu-button-bg-hover, var(--sk-color-surface-raised));
  --_button-fg: var(--sk-command-menu-button-fg, var(--sk-color-text));
  --_button-border: var(--sk-command-menu-button-border-color, var(--sk-color-border-strong));
  --_button-radius: var(--sk-command-menu-button-radius, var(--sk-radius-md));
}

.sk-command-menu__trigger {
  border: var(--sk-border-width) solid var(--_button-border);
  border-radius: var(--_button-radius);
  background: var(--_button-bg);
  color: var(--_button-fg);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  font-weight: var(--sk-weight-medium);
  min-block-size: var(--sk-size-tap-target);
}

.sk-command-menu__trigger:hover {
  background: var(--_button-bg-hover);
}

.sk-command-menu {
  margin: auto;
  inline-size: min(var(--_inline-size), calc(100vi - var(--sk-space-xl)));
  max-block-size: calc(100dvb - var(--sk-space-2xl));
  padding: 0;
  border: var(--sk-border-width) solid var(--_border);
  border-radius: var(--_radius);
  background: var(--_bg);
  color: var(--_fg);
  box-shadow: var(--_shadow);
  overflow: hidden;
}

.sk-command-menu__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sk-space-sm);
  padding-block: var(--sk-space-sm);
  padding-inline: var(--sk-space-md);
  border-block-end: var(--sk-border-width) solid var(--_border);
}

.sk-command-menu__title {
  font-size: var(--sk-text-md);
}

.sk-command-menu__close {
  border: 0;
  border-radius: var(--sk-radius-full);
  background: none;
  color: var(--_close-fg);
  font-size: var(--sk-text-lg);
  line-height: 1;
  min-inline-size: var(--sk-size-tap-target);
  min-block-size: var(--sk-size-tap-target);
}

.sk-command-menu__close:hover {
  background: var(--_close-bg-hover);
  color: var(--_fg);
}

/* --- filters: a radio group, one tab stop ------------------------------- */

.sk-command-menu__filters {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sk-space-2xs);
  margin: 0;
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-md);
  border: 0;
  border-block-end: var(--sk-border-width) solid var(--_border);
  min-inline-size: 0;
}

.sk-command-menu__legend {
  /* Out of flow and clipped, but still naming the group for assistive tech. */
  position: absolute;
  clip-path: inset(50%);
  white-space: nowrap;
}

.sk-command-menu__radio {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.sk-command-menu__filter {
  padding-block: var(--sk-space-2xs);
  padding-inline: var(--sk-space-sm);
  border-radius: var(--sk-radius-full);
  color: var(--_filter-fg);
  font-size: var(--sk-text-xs);
  cursor: pointer;
  display: flex;
  align-items: center;
  min-block-size: var(--sk-size-tap-target);
}

.sk-command-menu__radio:checked + .sk-command-menu__filter {
  background: var(--_accent);
  color: var(--_on-accent);
}

.sk-command-menu__radio:focus-visible + .sk-command-menu__filter {
  outline: var(--sk-ring-width) solid var(--sk-color-focus);
  outline-offset: var(--sk-ring-offset);
}

/* --- the list ------------------------------------------------------------ */

.sk-command-menu__list {
  margin: 0;
  padding: var(--sk-space-2xs);
  list-style: none;
  overflow-y: auto;
  max-block-size: 60dvb;
}

.sk-command-menu__item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sk-space-sm);
  padding-block: var(--sk-space-xs);
  padding-inline: var(--sk-space-sm);
  border-radius: var(--_item-radius);
  color: var(--_fg);
  font-size: var(--sk-text-sm);
  text-decoration: none;
  min-block-size: var(--sk-size-tap-target);
}

.sk-command-menu__item:hover {
  background: var(--_item-bg-hover);
}

.sk-command-menu__hint {
  color: var(--_hint-fg);
  font-size: var(--sk-text-2xs);
}

/* Filtering: one :has() condition per rule, never a chain. Hidden rows are
   display: none, so their links leave the accessibility tree and the tab order
   rather than lingering as invisible stops. */
.sk-command-menu:has(.sk-command-menu__radio:nth-of-type(2):checked)
  .sk-command-menu__row:not([data-group="pages"]),
.sk-command-menu:has(.sk-command-menu__radio:nth-of-type(3):checked)
  .sk-command-menu__row:not([data-group="actions"]) {
  display: none;
}

/* --- motion -------------------------------------------------------------- */

@media (prefers-reduced-motion: no-preference) {
  .sk-command-menu {
    opacity: 0;
    scale: 0.98;
    transition:
      opacity var(--sk-motion-fast) var(--sk-ease-out),
      scale var(--sk-motion-fast) var(--sk-ease-out);
  }

  .sk-command-menu[open] {
    opacity: 1;
    scale: 1;
  }

  @starting-style {
    .sk-command-menu[open] {
      opacity: 0;
      scale: 0.98;
    }
  }

  @supports (transition-behavior: allow-discrete) {
    .sk-command-menu {
      transition:
        opacity var(--sk-motion-fast) var(--sk-ease-out),
        scale var(--sk-motion-fast) var(--sk-ease-out),
        display var(--sk-motion-fast) allow-discrete,
        overlay var(--sk-motion-fast) allow-discrete;
    }
  }
}

Browser support

Baseline newly available

Shipped in every major engine, but only recently — older versions need the fallback.

Per-feature support, generated from web-features 3.35.0
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
<dialog>widely37799815.4379815.4
Invoker commandsnewly13513514426.213514426.2
:has()widely10510512115.410512115.4
Enhancements — the component works without these; they add polish. Their status does not affect the badge above.
FeatureBaselineChromeEdgeFirefoxSafariChrome AndroidFirefox AndroidSafari iOS
@starting-stylenewly11711712917.511712917.5
transition-behaviornewly11711712917.411712917.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 — no href, 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">&times;</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).

PropertyDefaultControls
--sk-command-menu-accent--sk-color-accentSelected filter chip background
--sk-command-menu-on-accent--sk-color-on-accentSelected filter chip text
--sk-command-menu-bg--sk-color-surface-raisedMenu background
--sk-command-menu-fg--sk-color-textMenu and item text
--sk-command-menu-border-color--sk-color-borderMenu border and the rules inside it
--sk-command-menu-radius--sk-radius-lgMenu corner radius
--sk-command-menu-shadow--sk-shadow-lgMenu elevation
--sk-command-menu-inline-size32remMenu width before the viewport clamp
--sk-command-menu-close-fg--sk-color-text-mutedClose button glyph
--sk-command-menu-close-bg-hover--sk-color-surfaceClose button background on hover
--sk-command-menu-filter-fg--sk-color-text-mutedUnselected filter chip text
--sk-command-menu-item-radius--sk-radius-mdItem corner radius
--sk-command-menu-item-bg-hover--sk-color-accent-subtleItem background on hover
--sk-command-menu-hint-fg--sk-color-text-subtleTrailing hint text on an item
--sk-command-menu-button-bg--sk-color-surfaceTrigger background
--sk-command-menu-button-bg-hover--sk-color-surface-raisedTrigger background on hover
--sk-command-menu-button-fg--sk-color-textTrigger text
--sk-command-menu-button-border-color--sk-color-border-strongTrigger border
--sk-command-menu-button-radius--sk-radius-mdTrigger 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

KeyBehaviour
Enter / Space on the triggerOpens the menu; focus moves inside
Arrow keys on the filtersChange category, which filters the list
TabMoves through the visible links; filtered-out links are skipped
Enter on a linkFollows it
EscCloses 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 namearia-labelledby pointing 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-group value. 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 usedBaseline statusBehavior without it
<dialog>widely
Invoker CommandsnewlyThe trigger does nothing. Same floor as dialog; see docs/polyfills.md for the opt-in consumer shim.
:has()widelyCategory filters do nothing; every row stays visible. The list is still usable.
@starting-style, allow-discretenewlyNo open/close animation.