Theme
Design your own
On this page (6)
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).

§API

Generated from command.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.

States

type CommandState = 'default' | 'open' - setState(name, config) takes the config of the state it names.

StateDescription
default
Closed.

No config.

open
Open as a modal, its search input focused.

No config.

Every element

MemberDescription
el.api.setState<S extends CommandState>(name: S, config?: CommandStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?CommandStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

el.api.getState(): { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.

Returns { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

el.api.render(state?: { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state?{ name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

el.store: Store<{ name: CommandState; config: CommandStateConfigs[CommandState] }>A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component.

Registry

MemberDescription
df$.shadcn.commandApi.setState<S extends CommandState>(el: HTMLElement, name: S, config?: CommandStateConfigs[S]): unknown
Enter a state: the DOM work runs (also when it is the current state), the store records it.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?CommandStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

df$.shadcn.commandApi.getState(el: HTMLElement): { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }
The state the element shows now - read back from the DOM, so it includes what the user changed.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

df$.shadcn.commandApi.render(state: { name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }): string
The element's markup in a state - the authored markup with that state applied; a pure function of the state.
ArgumentTypeDescription
state{ name: CommandState; config: CommandStateConfigs[CommandState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.commandApi.store(el: HTMLElement): Store<{ name: CommandState; config: CommandStateConfigs[CommandState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: CommandState; config: CommandStateConfigs[CommandState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

df$.shadcn.commandApi.commit<S extends CommandState>(el: HTMLElement, name: S, config?: CommandStateConfigs[S]): void
Record a state the element reached on its own (no DOM work) - for a component's own handlers.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?CommandStateConfigs[S]its config
df$.shadcn.commandStates: CommandState[]The declared states, 'default' first: default, open.

§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,
  componentState,
  bindComponent,
  bindGlobalKeys,
} from '../../../shared/state-api.js';
const df$ = defussGlobals();
const dfDollar = defussQuery();
const commandStates = ['default', 'open'];
// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each
// described, and every config field typed, described and named in the code.
/** setState() configs per state - the command palette's states take none. */
export interface CommandStateConfigs {
  /** Closed. */
  default: {};
  /** Open as a modal, its search input focused. */
  open: {};
}
/**
 * The markup of a state: 'open' carries `open`. render() applies it to a
 * detached copy; on the live element showModal()/close() (the native
 * protocol: top layer, focus, inert background) produce exactly this
 * attribute - the e2e render round trip proves they agree.
 */
function applyMarkup(el, stateName) {
  dfDollar(el).attr('open', stateName === 'open' ? '' : null);
}
/**
 * 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 = dfDollar(dialog).find<HTMLInputElement>('.command-input').get(0);
        if (input) input.focus();
      }
      break;
  }
}
/** The state a dialog shows: its `open` - read back after every change, and the state it starts in. */
const shown = (dialog: HTMLDialogElement) => (dialog.open ? 'open' : 'default');
/** Registry-level API; pass the dialog element explicitly. Unknown names throw. */
export const commandApi = componentState<HTMLDialogElement>({
  component: 'command',
  states: commandStates,
  apply: (dialog, state) => triggerStateChange(dialog, state.name, state.config),
  // the state is the dialog's `open`, as the schema observes it: ⌘K and the
  // trigger open it natively, without setState, and getState() must see that
  read: (dialog, state) => ({ name: shown(dialog), config: state.config }),
  markup: (el, state) => applyMarkup(el, state.name),
});
df$.commandApi = commandApi;
df$.commandStates = commandStates;
/* Cmd/Ctrl+K toggles the palette - through the shared global-key listener
   (src/shared/keys.ts), opted in for editable targets: ⌘K must work while
   typing in a field (the palette's own input included, where it closes it). */
bindGlobalKeys(
  (e) => {
    if (!(e.metaKey || e.ctrlKey) || e.altKey || e.key.toLowerCase() !== 'k') return;
    const dialog = dfDollar<HTMLDialogElement>('dialog.command').get(0);
    if (!dialog) return;
    e.preventDefault();
    if (dialog.open) dialog.close();
    else {
      dialog.showModal();
      const input = dfDollar(dialog).find<HTMLInputElement>('.command-input').get(0);
      if (input) input.focus();
    }
    return true;
  },
  { editable: true },
);
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() {
  dfDollar<HTMLDialogElement>('dialog.command:not([data-init])')
    .toArray()
    .forEach((dialog) => {
      dialog.dataset.init = '';
      // el.store + el.api (AGENTS.md "State through stores")
      bindComponent(dialog, commandApi, { name: shown(dialog), config: {} });
      const input = dfDollar(dialog).find<HTMLInputElement>('.command-input').get(0);
      const list = dfDollar(dialog).find('.command-list').get(0);
      const empty = dfDollar(dialog).find('.command-empty').get(0);
      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 as HTMLElement).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 clear an open palette. (Skipping the reset on
        // re-open keeps the last query, like macOS Spotlight.) The state itself
        // follows `open` through read().
        if (dialog.open) return;
        dfDollar(input).val('');
        filter('');
        dfDollar(list).find('.command-item[data-highlighted]').data('highlighted', null);
        highlightIndex = -1;
      });
    });
  dfDollar('[data-command-trigger]:not([data-init])')
    .toArray()
    .forEach((trigger) => {
      trigger.dataset.init = '';
      const dialog = dfDollar<HTMLDialogElement>('#' + CSS.escape(trigger.dataset.commandTrigger)).get(0);
      if (!dialog) return;
      trigger.addEventListener('click', () => {
        dialog.showModal();
        const input = dfDollar(dialog).find<HTMLInputElement>('.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