Theme
On this page (5)
Component Skill — components/command/component-skill.md

Native basis

<dialog> + search input for a command palette.

Web Platform APIs

<dialog>@starting-style::backdrop:focus-visibleprefers-reduced-motionforced-colors

Classes

.command.command-input-wrapper.command-input.command-list.command-group.command-group-heading.command-item.command-separator.command-shortcut.command-empty

Data attributes

• data-command-trigger

State attributes (managed by JS)

• data-highlighted

Wiring conventions

• data-command-trigger on any element - opens the component

Notes

• While the palette is modal, html:has(dialog.command:modal) sets overflow: hidden + scrollbar-gutter: stable - the page behind cannot scroll and its position is preserved for when the palette closes (no JS scroll-lock).

§Default

Click the button or press ⌘K to open. Use Arrow keys to navigate, Enter to select, Escape to close.

§Density

Set data-density on the component root to scale its internal whitespace. A whitespace policy, not a zoom: only gaps and padding scale (ratio 0.75 / 1 / 1.25), typography and fixed dimensions stay identical. comfortable matches the unsized default.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - closed (the authored state; trigger or Cmd/Ctrl+K opens it)
  • open - shown modally with the search input focused

The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/command-{state}.png.

Machine contract - verified against command.schema.json by bun run verify:

StateTypeValuesDefaultDescription
openbooleantrue, falsefalseShown via showModal(); hidden via close() (native open property).

§CSS view file

Styles for the command component. Uses design tokens for colors, spacing, and radius.

@layer components {
  dialog.command {
    border: none; border-radius: var(--radius-xl);
    background: var(--popover); color: var(--popover-foreground);
    padding: 0; max-width: 32rem; width: calc(100% - 2rem);
    box-shadow: var(--shadow-lg); margin: auto; position: fixed; inset: 0;
    top: 15%; bottom: auto; overflow: hidden;
    opacity: 0; transform: scale(0.98);
    transition: opacity 150ms ease, transform 150ms ease, display 150ms allow-discrete;
    &[open] { opacity: 1; transform: scale(1); }
    &::backdrop { background: oklch(0 0 0 / 0); transition: all 150ms ease, display 150ms allow-discrete; }
    &[open]::backdrop { background: oklch(0 0 0 / 0.45); }
  }
  @starting-style {
    dialog.command[open] { opacity: 0; transform: scale(0.98); }
    dialog.command[open]::backdrop { background: oklch(0 0 0 / 0); }
  }
  /* -- Scroll lock ------------------------------------------- */
  /* Page behind stays put while the palette is modal (see dialog.css):
     `:modal` + overflow:hidden freezes the viewport at its current offset. */
  html:has(dialog.command:modal) {
    overflow: hidden;
    scrollbar-gutter: stable;
  }
  .command-input-wrapper {
    display: flex; align-items: center; gap: 0.5rem;
    padding: 0.75rem 1rem; border-bottom: 1px solid var(--border);
    & svg { width: 1rem; height: 1rem; color: var(--muted-foreground); flex-shrink: 0; }
  }
  .command-input {
    flex: 1; border: none; background: transparent;
    font-size: 0.875rem; color: var(--foreground); outline: none; font-family: inherit;
    &::placeholder { color: var(--muted-foreground); }
  }
  .command-list { max-height: 18rem; overflow-y: auto; overscroll-behavior: contain; padding: 0.25rem; }
  .command-group { padding: 0.25rem 0; }
  .command-group-heading { margin: 0; padding: 0.375rem 0.5rem; font-size: 0.75rem; font-weight: 500; color: var(--muted-foreground); }
  .command-item {
    display: flex; align-items: center; gap: 0.5rem; width: 100%;
    padding: 0.5rem 0.5rem; border: none; border-radius: var(--radius-md);
    background: transparent; color: var(--foreground); font-size: 0.8125rem;
    font-family: inherit; text-align: left; cursor: pointer; outline: none;
    &:hover, &[data-highlighted] { background-color: var(--accent); }
    &:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
    &[aria-disabled="true"] { pointer-events: none; opacity: 0.5; }
    & svg { width: 1rem; height: 1rem; color: var(--muted-foreground); flex-shrink: 0; }
    /* Same as combobox-item: `display: flex` beats the UA [hidden] rule, and
       command.ts filter() hides non-matches via the hidden attribute. */
    &[hidden] { display: none; }
  }
  .command-separator { height: 1px; background-color: var(--border); margin: 0.25rem -0.25rem; }
  .command-shortcut { margin-inline-start: auto; font-size: 0.6875rem; color: var(--muted-foreground); font-family: var(--font-mono); }
  .command-empty { padding: 1.5rem; text-align: center; font-size: 0.875rem; color: var(--muted-foreground); }
  /* -- Accessibility ------------------------------------------ */
  @media (prefers-reduced-motion: reduce) {
    dialog.command { transition: none; opacity: 1; transform: none; }
    dialog.command::backdrop { transition: none; }
  }
  @media (prefers-contrast: more) {
    dialog.command { border: 2px solid var(--border); }
    .command-item {
      &:hover, &[data-highlighted] { outline: 1px solid var(--foreground); }
    }
  }
  @media (forced-colors: active) {
    dialog.command { border: 2px solid CanvasText; }
    .command-item {
      &:hover, &[data-highlighted] { forced-color-adjust: none; background: Highlight; color: HighlightText; }
    }
    .command-separator { background-color: CanvasText; }
  }
  /* -- Density ----------------------------------------------------
     data-density on the dialog.command root scales the rows of the palette.
     comfortable == the unsized default. (0,2,1) beats the base (0,1,0). */
  dialog.command:where([data-density="compact"]) {
    & .command-input-wrapper { padding: 0.5rem 0.75rem; }
    & .command-item { padding: 0.375rem 0.5rem; }
    & .command-group-heading { padding: 0.25rem 0.5rem; }
  }
  dialog.command:where([data-density="comfortable"]) {
    & .command-input-wrapper { padding: 0.75rem 1rem; }
    & .command-item { padding: 0.5rem 0.5rem; }
    & .command-group-heading { padding: 0.375rem 0.5rem; }
  }
  dialog.command:where([data-density="spacious"]) {
    & .command-input-wrapper { padding: 1rem 1.25rem; }
    & .command-item { padding: 0.625rem 0.5rem; }
    & .command-group-heading { padding: 0.5rem 0.5rem; }
  }
}

§JavaScript view file

Interaction logic for the command component. Uses data attributes for wiring.

// -- Command --------------------------------------------------
// Command palette dialog with search filtering, keyboard navigation, and
// Cmd/Ctrl+K shortcut, plus the named-state API so agents/tests can drive
// open/closed by name (AGENTS.md "State API").
// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —
// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.
// defussQuery: the callable runtime - filtering flags + the move-highlight
// marker ride query scalar writes; membership stays authored (flag-based
// filtering, no renderer - §3 command row: keyed morph only once a data
// source drives the result set, which the docs palette may adopt later).
import { defussGlobals, defussQuery } from '../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const commandStates = ['default', 'open'];
/**
 * UI side of setState: 'default' closes, 'open' opens modally and focuses
 * the search input (same affordance as the trigger/keyboard shortcut).
 */
function triggerStateChange(dialog, stateName, _config) {
  switch (stateName) {
    case 'default':
      if (dialog.open) dialog.close();
      break;
    case 'open':
      if (!dialog.open) dialog.showModal();
      {
        const input = dialog.querySelector('.command-input');
        if (input) input.focus();
      }
      break;
  }
}
/** Registry-level API; pass the dialog element explicitly. Unknown names throw. */
export const commandApi = {
  setState(dialog, stateName, config = {}) {
    if (!commandStates.includes(stateName)) {
      throw new Error(`command: unknown state "${stateName}" (supported: ${commandStates.join(', ')})`);
    }
    triggerStateChange(dialog, stateName, config);
    // state lives on the ELEMENT, not the module (multiple palettes per page)
    dialog.dataset.stateName = stateName;
    dialog._stateConfig = config;
  },
  getState(dialog) {
    return { name: dialog.dataset.stateName || 'default', config: dialog._stateConfig ?? {} };
  },
};
df$.commandApi = commandApi;
df$.commandStates = commandStates;
/* Cmd/Ctrl+K handler - added once at module level */
let commandKeydownAdded = false;
if (!commandKeydownAdded) {
  commandKeydownAdded = true;
  document.addEventListener('keydown', (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
      const dialog = document.querySelector('dialog.command');
      if (!dialog) return;
      e.preventDefault();
      if (dialog.open) { dialog.close(); }
      else { dialog.showModal(); const input = dialog.querySelector('.command-input'); if (input) input.focus(); }
    }
  });
}
function getVisibleItems(list) {
  return Array.from(dfDollar(list).find('.command-item:not([hidden]):not([aria-disabled="true"])'));
}
function highlightItem(list, index) {
  const visible = getVisibleItems(list);
  dfDollar(list).find('.command-item[data-highlighted]').data('highlighted', null);
  if (visible.length === 0) return -1;
  const clamped = ((index % visible.length) + visible.length) % visible.length;
  dfDollar(visible[clamped]).data('highlighted', '');
  visible[clamped].scrollIntoView({ block: 'nearest' }); // native scroll stays native
  return clamped;
}
function init() {
document.querySelectorAll('dialog.command:not([data-init])').forEach((dialog) => {
    dialog.dataset.init = '';
    // bind-scope the api per instance: `$('#demo-cmd').api.setState('open')`
    dialog.api = {
      setState: (stateName, config) => commandApi.setState(dialog, stateName, config),
      getState: () => commandApi.getState(dialog),
    };
    const input = dialog.querySelector('.command-input');
    const list = dialog.querySelector('.command-list');
    const empty = dialog.querySelector('.command-empty');
    if (!input || !list) return;
    let highlightIndex = -1;
    // query the LIVE list on every filter (KISS): item nodes may be replaced
    // after init (e.g. the docs palette feeds itself from a generated index),
    // a cached snapshot would silently keep filtering detached nodes
    const filter = (q) => {
      const query = q.toLowerCase(); let hasVisible = false;
      const $items = dfDollar(list).find('.command-item');
      // flag-based filtering through query scalars: nodes keep identity (§3)
      $items.each(function (this: HTMLElement) {
        const match = !query || this.textContent.toLowerCase().includes(query);
        dfDollar(this).prop('hidden', !match); if (match) hasVisible = true;
      });
      dfDollar(list).find('.command-group').each(function (this: HTMLElement) {
        dfDollar(this).prop('hidden', dfDollar(this).find('.command-item:not([hidden])').length === 0);
      });
      dfDollar(list).find('.command-separator').prop('hidden', !!query);
      if (empty) dfDollar(empty).prop('hidden', hasVisible);
      highlightIndex = highlightItem(list, 0);
    };
    input.addEventListener('input', () => { filter(input.value); });
    input.addEventListener('keydown', (e) => {
      const visible = getVisibleItems(list);
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        highlightIndex = highlightItem(list, highlightIndex + 1);
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        highlightIndex = highlightItem(list, highlightIndex - 1);
      } else if (e.key === 'Enter') {
        e.preventDefault();
        if (visible[highlightIndex]) { visible[highlightIndex].click(); }
      } else if (e.key === 'Home') {
        e.preventDefault();
        highlightIndex = highlightItem(list, 0);
      } else if (e.key === 'End') {
        e.preventDefault();
        highlightIndex = highlightItem(list, visible.length - 1);
      }
    });
    dialog.addEventListener('click', (e) => {
      if (e.target === dialog) dialog.close();
      if (e.target.closest('.command-item')) dialog.close();
    });
    dialog.addEventListener('close', () => {
      // `close` fires AFTER the exit transition (display allow-discrete), so a
      // fast re-open (setState/⌘K within 150ms) can beat the queued event - a
      // stale one must not downgrade an open palette to 'default'. (Skipping
      // the reset on re-open keeps the last query, like macOS Spotlight.)
      if (dialog.open) return;
      // reflect the actual UI state: Escape/item-click/backdrop close = 'default'
      dialog.dataset.stateName = 'default'; // State API marker stays dataset.*
      dfDollar(input).val('');
      filter('');
      dfDollar(list).find('.command-item[data-highlighted]').data('highlighted', null);
      highlightIndex = -1;
    });
  });
document.querySelectorAll('[data-command-trigger]:not([data-init])').forEach((trigger) => {
  trigger.dataset.init = '';
  const dialog = document.getElementById(trigger.dataset.commandTrigger);
  if (!dialog) return;
  trigger.addEventListener('click', () => {
    dialog.showModal();
    const input = dfDollar(dialog).find('.command-input')[0];
    if (input) (input as HTMLElement).focus(); // native focus protocol stays native
  });
});
}
init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });

Comments, ideas or improvements? Edit this page's source on GitHub