Command Palette

  • Go to dashboardGD
  • Go to projectsGP
  • Go to settingsGS
  • Create a new pageN
  • Invite a teammateI
  • Switch design styleS
  • Read the documentation?
  • Contact support

@use 'sprucecss/scss/spruce' as *;

.command-palette-trigger {
  kbd {
    font-size: 0.75em;
    margin-inline-start: spacer('s');
  }
}

.command-palette {
  background-color: color('surface');
  border: config('border-width-base', $display) solid color('border');
  border-radius: radius('lg');
  box-shadow: shadow('xl');
  inline-size: min(36rem, calc(100vi - #{spacer('m')}));
  margin-block-start: 12dvb;
  max-inline-size: none;
  padding: 0;

  &::backdrop {
    backdrop-filter: blur(2px);
    background-color: color('overlay');
  }

  &__search {
    align-items: center;
    border-block-end: config('border-width-base', $display) solid color('border');
    display: flex;
    gap: spacer('s');
    padding: spacer('s') spacer('m');

    svg {
      block-size: 1.25rem;
      color: color('muted');
      flex-shrink: 0;
      inline-size: 1.25rem;
    }

    kbd {
      font-size: 0.75rem;
    }
  }

  &__input {
    background: transparent;
    border: 0;
    color: color('heading');
    flex-grow: 1;
    font: inherit;
    font-size: config('font-size-lg', $typography);
    min-inline-size: 0;

    &:focus {
      outline: none;
    }
  }

  &__results {
    max-block-size: min(24rem, 60dvb);
    overflow-y: auto;
    padding: spacer('xs');
  }

  &__group {
    color: color('muted');
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    margin: spacer('xs') spacer('s') spacer('xxs');
    text-transform: uppercase;
  }

  &__list {
    @include clear-list;
  }

  &__option {
    align-items: center;
    border-radius: radius('md');
    color: color('heading');
    cursor: pointer;
    display: flex;
    gap: spacer('s');
    justify-content: space-between;
    margin: 0 !important;
    padding: spacer('xs') spacer('s');

    &[aria-selected='true'] {
      background-color: color-alpha('primary', 0.12);
      color: color('primary');
    }
  }

  &__keys {
    display: flex;
    gap: spacer('xxs');

    kbd {
      font-size: 0.75rem;
    }
  }

  &__empty {
    color: color('muted');
    padding: spacer('l');
    text-align: center;
  }
}
<button class="btn btn--outline-secondary command-palette-trigger" type="button" data-command-open aria-haspopup="dialog" aria-keyshortcuts="Control+K Meta+K">
    <svg class="btn__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
    Search commands…
    <kbd>Ctrl K</kbd>
</button>
<dialog class="command-palette" aria-label="Command palette" data-command-palette>
    <div class="command-palette__search">
        <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
        <input class="command-palette__input" type="text" role="combobox" aria-expanded="true" aria-controls="command-palette-listbox" aria-autocomplete="list" aria-label="Search commands" placeholder="Type a command or search…" autocomplete="off" data-command-input>
        <kbd>Esc</kbd>
    </div>
    <div class="command-palette__results" id="command-palette-listbox" role="listbox" aria-label="Commands">
            <p class="command-palette__group" id="command-group-navigation" aria-hidden="true">Navigation</p>
            <ul class="command-palette__list" role="group" aria-labelledby="command-group-navigation">
                <li class="command-palette__option" role="option" id="command-go-to-dashboard" aria-selected="false" data-command>Go to dashboard<span class="command-palette__keys"><kbd>G</kbd><kbd>D</kbd></span></li>
                <li class="command-palette__option" role="option" id="command-go-to-projects" aria-selected="false" data-command>Go to projects<span class="command-palette__keys"><kbd>G</kbd><kbd>P</kbd></span></li>
                <li class="command-palette__option" role="option" id="command-go-to-settings" aria-selected="false" data-command>Go to settings<span class="command-palette__keys"><kbd>G</kbd><kbd>S</kbd></span></li>
            </ul>
            <p class="command-palette__group" id="command-group-actions" aria-hidden="true">Actions</p>
            <ul class="command-palette__list" role="group" aria-labelledby="command-group-actions">
                <li class="command-palette__option" role="option" id="command-create-a-new-page" aria-selected="false" data-command>Create a new page<span class="command-palette__keys"><kbd>N</kbd></span></li>
                <li class="command-palette__option" role="option" id="command-invite-a-teammate" aria-selected="false" data-command>Invite a teammate<span class="command-palette__keys"><kbd>I</kbd></span></li>
                <li class="command-palette__option" role="option" id="command-switch-design-style" aria-selected="false" data-command>Switch design style<span class="command-palette__keys"><kbd>S</kbd></span></li>
            </ul>
            <p class="command-palette__group" id="command-group-help" aria-hidden="true">Help</p>
            <ul class="command-palette__list" role="group" aria-labelledby="command-group-help">
                <li class="command-palette__option" role="option" id="command-read-the-documentation" aria-selected="false" data-command>Read the documentation<span class="command-palette__keys"><kbd>?</kbd></span></li>
                <li class="command-palette__option" role="option" id="command-contact-support" aria-selected="false" data-command>Contact support<span class="command-palette__keys"></span></li>
            </ul>
    </div>
    <p class="command-palette__empty" hidden data-command-empty>No results.</p>
    <p class="sr-only" aria-live="polite" data-command-status></p>
</dialog>
(() => {
  const palette = document.querySelector('[data-command-palette]');

  if (!palette) {
    return;
  }

  const input = palette.querySelector('[data-command-input]');
  const options = [...palette.querySelectorAll('[data-command]')];
  const status = palette.querySelector('[data-command-status]');
  const empty = palette.querySelector('[data-command-empty]');
  let visible = options;
  let active = -1;

  const highlight = (index) => {
    options.forEach((option) => option.setAttribute('aria-selected', 'false'));
    active = visible.length ? (index + visible.length) % visible.length : -1;

    if (active > -1) {
      visible[active].setAttribute('aria-selected', 'true');
      visible[active].scrollIntoView({ block: 'nearest' });
      input.setAttribute('aria-activedescendant', visible[active].id);
    } else {
      input.removeAttribute('aria-activedescendant');
    }
  };

  const filter = () => {
    const query = input.value.trim().toLowerCase();

    options.forEach((option) => {
      option.hidden = !option.textContent.toLowerCase().includes(query);
    });

    palette.querySelectorAll('.command-palette__list').forEach((list) => {
      const isEmpty = !list.querySelector('[data-command]:not([hidden])');
      list.hidden = isEmpty;
      list.previousElementSibling.hidden = isEmpty;
    });

    visible = options.filter((option) => !option.hidden);
    empty.hidden = visible.length > 0;
    status.textContent = `${visible.length} ${visible.length === 1 ? 'result' : 'results'}`;
    highlight(0);
  };

  const run = (option) => {
    palette.dispatchEvent(new CustomEvent('command', { detail: option.firstChild.textContent.trim() }));
    palette.close();
  };

  const open = () => {
    input.value = '';
    filter();
    palette.showModal();
    input.focus();
  };

  document.querySelectorAll('[data-command-open]').forEach((button) => button.addEventListener('click', open));

  document.addEventListener('keydown', (event) => {
    if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
      event.preventDefault();
      palette.open ? palette.close() : open();
    }
  });

  input.addEventListener('input', filter);

  input.addEventListener('keydown', (event) => {
    if (event.key === 'ArrowDown') { event.preventDefault(); highlight(active + 1); }
    if (event.key === 'ArrowUp') { event.preventDefault(); highlight(active - 1); }
    if (event.key === 'Home') { event.preventDefault(); highlight(0); }
    if (event.key === 'End') { event.preventDefault(); highlight(visible.length - 1); }
    if (event.key === 'Enter' && visible[active]) { event.preventDefault(); run(visible[active]); }
  });

  options.forEach((option) => {
    option.addEventListener('click', () => run(option));
    option.addEventListener('pointermove', () => highlight(visible.indexOf(option)));
  });

  palette.addEventListener('click', (event) => {
    if (event.target === palette) palette.close();
  });
})();

Command Palette

On this page

A keyboard-first command palette (like Ctrl + K in many apps) to search and run actions.

Technical Details

  • It is a modal dialog with a combobox input and a grouped listbox (the WAI-ARIA combobox pattern): the focus stays in the input, and aria-activedescendant points to the highlighted option.
  • Ctrl/⌘ + K opens it from anywhere; ↑/↓ move, Home/End jump, Enter runs the command, Esc closes it, and the focus returns to the trigger.
  • Typing filters the options (groups without results hide), and a polite live region announces the number of results.
  • The trigger advertises the shortcut with aria-keyshortcuts.
  • Replace the data-command items with your own actions: the script dispatches a command event on the dialog with the chosen option's text.

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our UI library of accessible components built with Spruce CSS.

Blocks and Pages

Full-width page blocks and complete page templates, ready to copy.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.