Autocomplete

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

.autocomplete {
  @include layout-stack('xs');

  &__control {
    position: relative;
  }

  &__listbox {
    @include clear-list;
    background-color: color('surface');
    border: config('border-width-base', $display) solid color('border');
    border-radius: radius('md');
    box-shadow: shadow('lg');
    inline-size: 100%;
    inset-block-start: calc(100% + #{spacer('xxs')});
    inset-inline-start: 0;
    max-block-size: 15rem;
    overflow-y: auto;
    padding: spacer('xxs');
    position: absolute;
    z-index: z-index('dropdown');

    li {
      border-radius: radius('sm');
      cursor: pointer;
      margin: 0 !important;
      padding: spacer('xs') spacer('s');

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

      mark {
        background-color: transparent;
        color: inherit;
        font-weight: 700;
        padding: 0;
      }
    }
  }
}
<div class="autocomplete" data-autocomplete>
    <label class="form-label" id="autocomplete-label" for="autocomplete-input">Country</label>
    <div class="autocomplete__control">
        <input class="form-control" id="autocomplete-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="autocomplete-listbox" autocomplete="off" placeholder="Start typing…" data-autocomplete-input>
        <ul class="autocomplete__listbox" id="autocomplete-listbox" role="listbox" aria-labelledby="autocomplete-label" hidden data-autocomplete-listbox>
            <li role="option" id="autocomplete-option-0" aria-selected="false">Argentina</li>
            <li role="option" id="autocomplete-option-1" aria-selected="false">Australia</li>
            <li role="option" id="autocomplete-option-2" aria-selected="false">Austria</li>
            <li role="option" id="autocomplete-option-3" aria-selected="false">Belgium</li>
            <li role="option" id="autocomplete-option-4" aria-selected="false">Brazil</li>
            <li role="option" id="autocomplete-option-5" aria-selected="false">Canada</li>
            <li role="option" id="autocomplete-option-6" aria-selected="false">Denmark</li>
            <li role="option" id="autocomplete-option-7" aria-selected="false">Finland</li>
            <li role="option" id="autocomplete-option-8" aria-selected="false">France</li>
            <li role="option" id="autocomplete-option-9" aria-selected="false">Germany</li>
            <li role="option" id="autocomplete-option-10" aria-selected="false">Hungary</li>
            <li role="option" id="autocomplete-option-11" aria-selected="false">Ireland</li>
            <li role="option" id="autocomplete-option-12" aria-selected="false">Italy</li>
            <li role="option" id="autocomplete-option-13" aria-selected="false">Japan</li>
            <li role="option" id="autocomplete-option-14" aria-selected="false">Mexico</li>
            <li role="option" id="autocomplete-option-15" aria-selected="false">Netherlands</li>
            <li role="option" id="autocomplete-option-16" aria-selected="false">Norway</li>
            <li role="option" id="autocomplete-option-17" aria-selected="false">Portugal</li>
            <li role="option" id="autocomplete-option-18" aria-selected="false">Spain</li>
            <li role="option" id="autocomplete-option-19" aria-selected="false">Sweden</li>
            <li role="option" id="autocomplete-option-20" aria-selected="false">Switzerland</li>
            <li role="option" id="autocomplete-option-21" aria-selected="false">United Kingdom</li>
            <li role="option" id="autocomplete-option-22" aria-selected="false">United States</li>
        </ul>
    </div>
    <p class="sr-only" aria-live="polite" data-autocomplete-status></p>
</div>
(() => {
  const escape = (text) => text.replace(/[&<>"]/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[char]);

  document.querySelectorAll('[data-autocomplete]').forEach((component) => {
    const input = component.querySelector('[data-autocomplete-input]');
    const listbox = component.querySelector('[data-autocomplete-listbox]');
    const status = component.querySelector('[data-autocomplete-status]');
    const options = [...listbox.querySelectorAll('[role="option"]')];
    const labels = options.map((option) => option.textContent);
    let visible = [];
    let active = -1;

    const close = () => {
      listbox.hidden = true;
      input.setAttribute('aria-expanded', 'false');
      input.removeAttribute('aria-activedescendant');
      active = -1;
    };

    const highlight = (index) => {
      options.forEach((option) => option.setAttribute('aria-selected', 'false'));
      active = (index + visible.length) % visible.length;
      visible[active].setAttribute('aria-selected', 'true');
      visible[active].scrollIntoView({ block: 'nearest' });
      input.setAttribute('aria-activedescendant', visible[active].id);
    };

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

      visible = [];
      options.forEach((option, index) => {
        const label = labels[index];
        const position = label.toLowerCase().indexOf(query);
        const isMatch = position > -1;

        option.hidden = !isMatch;
        option.setAttribute('aria-selected', 'false');

        if (isMatch) {
          option.innerHTML = query
            ? `${escape(label.slice(0, position))}<mark>${escape(label.slice(position, position + query.length))}</mark>${escape(label.slice(position + query.length))}`
            : escape(label);
          visible.push(option);
        }
      });

      active = -1;
      listbox.hidden = visible.length === 0;
      input.setAttribute('aria-expanded', String(visible.length > 0));
      status.textContent = `${visible.length} ${visible.length === 1 ? 'suggestion' : 'suggestions'}`;
    };

    const select = (option) => {
      input.value = option.textContent;
      close();
    };

    input.addEventListener('input', open);
    input.addEventListener('focus', () => input.value && open());

    input.addEventListener('keydown', (event) => {
      if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
        event.preventDefault();
        if (listbox.hidden) open();
        if (visible.length) highlight(active + (event.key === 'ArrowDown' ? 1 : -1));
      } else if (event.key === 'Enter' && active > -1) {
        event.preventDefault();
        select(visible[active]);
      } else if (event.key === 'Escape') {
        if (listbox.hidden) input.value = '';
        close();
      }
    });

    options.forEach((option) => {
      option.addEventListener('mousedown', (event) => event.preventDefault());
      option.addEventListener('click', () => select(option));
    });

    document.addEventListener('click', (event) => {
      if (!component.contains(event.target)) close();
    });
  });
})();

Autocomplete

On this page

A single-value autocomplete: a text input with a filtered suggestion list.

Technical Details

  • It follows the WAI-ARIA combobox pattern with list autocomplete: the input has role="combobox", controls a listbox, and the highlighted option is referenced with aria-activedescendant, so the focus never leaves the input.
  • ↓/↑ open the list and move through it, Enter selects, Esc closes (a second Esc clears the input), and a click outside closes it. The matching part of each suggestion is highlighted with mark.
  • A polite live region announces the number of suggestions.
  • Need multiple values? Use the Combobox or the Tag Input. For a short list, a native select (or input with a datalist) is simpler.

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.