Combobox

    Pick as many as you like. Backspace removes the last one.

    @use 'sprucecss/scss/spruce' as *;
    
    .combobox {
      display: flex;
      flex-direction: column;
      gap: spacer('xs');
    
      &__inner {
        position: relative;
      }
    
      &__control {
        padding-inline-end: 2.5rem;
      }
    
      // An element instead of a background image, so it follows the text direction and the color mode.
      &__chevron {
        block-size: 1rem;
        color: color('muted');
        inline-size: 1rem;
        inset-block-start: 50%;
        inset-inline-end: spacer('s');
        pointer-events: none;
        position: absolute;
        translate: 0 -50%;
      }
    
      &__control[aria-expanded='true'] + &__chevron {
        rotate: 180deg;
      }
    
      &__listbox,
      &__empty {
        background-color: color('surface');
        border: config('border-width-base', $display) solid color('border');
        border-radius: radius('md');
        box-shadow: shadow('lg');
        inset-block-start: calc(100% + #{spacer('xxs')});
        inset-inline: 0;
        margin: 0;
        padding: spacer('xxs');
        position: absolute;
        z-index: z-index('dropdown');
    
        &[hidden] {
          display: none;
        }
      }
    
      &__listbox {
        @include clear-list;
        @include scrollbar;
        max-block-size: 15rem;
        overflow-y: auto;
    
        > li {
          margin: 0 !important;
        }
      }
    
      &__empty {
        color: color('muted');
        padding: spacer('xs') spacer('s');
      }
    
      &__option {
        align-items: center;
        border-radius: radius('sm');
        cursor: pointer;
        display: flex;
        gap: spacer('xs');
        padding: spacer('xs') spacer('s');
        user-select: none;
    
        &[hidden] {
          display: none;
        }
    
        &:hover,
        &.is-active {
          background-color: color-alpha('primary', 0.1);
          color: color('primary');
        }
    
        &.is-active {
          box-shadow: inset 0 0 0 2px color('focus-ring');
        }
    
        &[aria-selected='true'] {
          font-weight: 700;
        }
      }
    
      &__swatch {
        background-color: var(--swatch);
        block-size: 1rem;
        border-radius: 50%;
        box-shadow: inset 0 0 0 1px hsl(0deg 0% 0% / 20%);
        flex-shrink: 0;
        inline-size: 1rem;
      }
    
      &__check {
        block-size: 1rem;
        inline-size: 1rem;
        margin-inline-start: auto;
        visibility: hidden;
      }
    
      &__option[aria-selected='true'] &__check {
        visibility: visible;
      }
    
      &__selected {
        @include clear-list;
        display: flex;
        flex-wrap: wrap;
        gap: spacer('xs');
    
        &:empty {
          display: none;
        }
    
        > li {
          margin: 0 !important;
          margin-block: 0;
          padding-block: 0;
        }
      }
    
      &__reset {
        align-self: start;
    
        &[hidden] {
          display: none;
        }
      }
    }
    
    .combobox-item {
      align-items: center;
      background-color: color-alpha('primary', 0.1);
      border-radius: radius('pill');
      color: color('primary');
      display: flex;
      font-size: config('font-size-sm', $typography);
      font-weight: 600;
      gap: spacer('xxs');
      line-height: 1;
      padding-block: spacer('xxs');
      padding-inline: spacer('xs') spacer('xxs');
    
      &__remove {
        @include clear-btn;
        align-items: center;
        block-size: 1.5rem;
        border-radius: 50%;
        display: inline-flex;
        inline-size: 1.5rem;
        justify-content: center;
    
        &:hover {
          background-color: color-alpha('primary', 0.15);
        }
    
        &:focus-visible {
          outline: 2px solid color('focus-ring');
          outline-offset: 1px;
        }
    
        svg {
          block-size: 0.75rem;
          inline-size: 0.75rem;
        }
      }
    }
    <div class="form-group">
        <label class="form-label" id="colors-label" for="colors">Colors</label>
        <div class="combobox" data-combobox data-name="colors[]">
            <ul class="combobox__selected" aria-label="Selected colors" data-combobox-selected></ul>
            <div class="combobox__inner">
                <input
                    class="form-control combobox__control"
                    id="colors"
                    type="text"
                    role="combobox"
                    autocomplete="off"
                    aria-autocomplete="list"
                    aria-expanded="false"
                    aria-controls="colors-listbox"
                    aria-describedby="colors-help"
                    placeholder="Search for a color"
                >
                <svg class="combobox__chevron" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6 9 6 6 6-6"/></svg>
                <ul class="combobox__listbox" id="colors-listbox" role="listbox" aria-labelledby="colors-label" aria-multiselectable="true" hidden>
                <li class="combobox__option" id="colors-option-red" role="option" aria-selected="false" data-value="red">
                    <span class="combobox__swatch" style="--swatch: hsl(0deg 80% 55%);" aria-hidden="true"></span>
                    Red
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-orange" role="option" aria-selected="false" data-value="orange">
                    <span class="combobox__swatch" style="--swatch: hsl(28deg 95% 55%);" aria-hidden="true"></span>
                    Orange
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-yellow" role="option" aria-selected="false" data-value="yellow">
                    <span class="combobox__swatch" style="--swatch: hsl(48deg 95% 55%);" aria-hidden="true"></span>
                    Yellow
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-green" role="option" aria-selected="false" data-value="green">
                    <span class="combobox__swatch" style="--swatch: hsl(140deg 55% 42%);" aria-hidden="true"></span>
                    Green
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-teal" role="option" aria-selected="false" data-value="teal">
                    <span class="combobox__swatch" style="--swatch: hsl(180deg 60% 35%);" aria-hidden="true"></span>
                    Teal
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-blue" role="option" aria-selected="true" data-value="blue">
                    <span class="combobox__swatch" style="--swatch: hsl(215deg 85% 55%);" aria-hidden="true"></span>
                    Blue
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-purple" role="option" aria-selected="false" data-value="purple">
                    <span class="combobox__swatch" style="--swatch: hsl(270deg 60% 55%);" aria-hidden="true"></span>
                    Purple
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-pink" role="option" aria-selected="false" data-value="pink">
                    <span class="combobox__swatch" style="--swatch: hsl(330deg 80% 70%);" aria-hidden="true"></span>
                    Pink
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-brown" role="option" aria-selected="false" data-value="brown">
                    <span class="combobox__swatch" style="--swatch: hsl(25deg 45% 35%);" aria-hidden="true"></span>
                    Brown
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-white" role="option" aria-selected="false" data-value="white">
                    <span class="combobox__swatch" style="--swatch: hsl(0deg 0% 100%);" aria-hidden="true"></span>
                    White
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                <li class="combobox__option" id="colors-option-black" role="option" aria-selected="false" data-value="black">
                    <span class="combobox__swatch" style="--swatch: hsl(0deg 0% 8%);" aria-hidden="true"></span>
                    Black
                    <svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
                </li>
                </ul>
                <p class="combobox__empty" hidden data-combobox-empty>No colors found.</p>
            </div>
            <p class="form-description" id="colors-help">Pick as many as you like. Backspace removes the last one.</p>
            <button class="btn btn--outline-primary btn--sm combobox__reset" type="button" hidden data-combobox-reset>Clear all colors</button>
            <p class="sr-only" aria-live="polite" data-combobox-status></p>
        </div>
    </div>
    document.querySelectorAll('[data-combobox]').forEach((combobox) => {
      const input = combobox.querySelector('[role="combobox"]');
      const listbox = combobox.querySelector('[role="listbox"]');
      const options = [...listbox.querySelectorAll('[role="option"]')];
      const selectedList = combobox.querySelector('[data-combobox-selected]');
      const empty = combobox.querySelector('[data-combobox-empty]');
      const reset = combobox.querySelector('[data-combobox-reset]');
      const status = combobox.querySelector('[data-combobox-status]');
      const label = (option) => option.textContent.trim();
      const visible = () => options.filter((option) => !option.hidden);
      let active = null;
    
      const setActive = (option) => {
        options.forEach((item) => item.classList.toggle('is-active', item === option));
        active = option;
    
        if (option) {
          input.setAttribute('aria-activedescendant', option.id);
          option.scrollIntoView({ block: 'nearest' });
        } else {
          input.removeAttribute('aria-activedescendant');
        }
      };
    
      const open = () => {
        const results = visible().length;
    
        listbox.hidden = results === 0;
        empty.hidden = results > 0;
        input.setAttribute('aria-expanded', String(results > 0));
      };
    
      const close = () => {
        listbox.hidden = true;
        empty.hidden = true;
        input.setAttribute('aria-expanded', 'false');
        setActive(null);
      };
    
      // Render the chips and the hidden inputs from the selected options.
      const render = () => {
        const selected = options.filter((option) => option.getAttribute('aria-selected') === 'true');
    
        selectedList.replaceChildren(...selected.map((option) => {
          const item = document.createElement('li');
    
          item.className = 'combobox-item';
          item.innerHTML = '<span></span><button class="combobox-item__remove" type="button"><span class="sr-only"></span><svg aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 6 6 18M6 6l12 12"/></svg></button><input type="hidden">';
          item.querySelector('span').textContent = label(option);
          item.querySelector('.sr-only').textContent = `Remove ${label(option)}`;
          item.querySelector('input').name = combobox.dataset.name;
          item.querySelector('input').value = option.dataset.value;
          item.querySelector('button').addEventListener('click', () => {
            toggle(option);
            input.focus();
          });
    
          return item;
        }));
    
        reset.hidden = selected.length === 0;
      };
    
      const toggle = (option) => {
        const selected = option.getAttribute('aria-selected') !== 'true';
    
        option.setAttribute('aria-selected', String(selected));
        status.textContent = `${label(option)} ${selected ? 'selected' : 'removed'}.`;
        render();
        combobox.dispatchEvent(new CustomEvent('combobox:change', {
          bubbles: true,
          detail: options.filter((item) => item.getAttribute('aria-selected') === 'true').map((item) => item.dataset.value)
        }));
      };
    
      const filter = () => {
        const query = input.value.trim().toLowerCase();
    
        options.forEach((option) => { option.hidden = !label(option).toLowerCase().includes(query); });
        setActive(null);
        open();
    
        const results = visible().length;
        status.textContent = query ? `${results} ${results === 1 ? 'result' : 'results'} available.` : '';
      };
    
      const move = (step) => {
        const items = visible();
    
        if (listbox.hidden) open();
        if (!items.length) return;
    
        const index = items.indexOf(active);
        setActive(items[index === -1 ? (step > 0 ? 0 : items.length - 1) : (index + step + items.length) % items.length]);
      };
    
      input.addEventListener('input', filter);
      input.addEventListener('click', () => (listbox.hidden ? open() : close()));
    
      input.addEventListener('keydown', (event) => {
        switch (event.key) {
          case 'ArrowDown':
            event.preventDefault();
            if (event.altKey) { open(); } else { move(1); }
            break;
          case 'ArrowUp':
            event.preventDefault();
            move(-1);
            break;
          case 'Enter':
            if (active) {
              event.preventDefault();
              toggle(active);
            }
            break;
          case 'Escape':
            if (!listbox.hidden || !empty.hidden) {
              close();
            } else if (input.value) {
              input.value = '';
              filter();
              close();
            }
            break;
          case 'Backspace': {
            // An empty input: remove the last selected item.
            const last = options.filter((option) => option.getAttribute('aria-selected') === 'true').pop();
            if (!input.value && last) toggle(last);
            break;
          }
          case 'Tab':
            close();
            break;
        }
      });
    
      options.forEach((option) => {
        // Keep the focus in the input while clicking an option.
        option.addEventListener('mousedown', (event) => event.preventDefault());
        option.addEventListener('click', () => {
          toggle(option);
          setActive(option);
        });
      });
    
      reset.addEventListener('click', () => {
        options.forEach((option) => option.setAttribute('aria-selected', 'false'));
        render();
        status.textContent = 'All colors removed.';
        input.focus();
      });
    
      document.addEventListener('click', (event) => {
        if (!combobox.contains(event.target)) close();
      });
    
      render();
    });

    Combobox

    A searchable multi-select: type to filter a list, pick as many options as needed, and manage the choices as removable chips.

    Anatomy

    • .combobox wraps everything; data-name sets the name of the generated hidden inputs (like colors[]).
    • .combobox__selected is the list of chips (.combobox-item), each with a remove button and a hidden input for the form.
    • .combobox__control is the text input with role="combobox"; .combobox__chevron is its decorative icon.
    • .combobox__listbox is the role="listbox" with aria-multiselectable="true"; the options (.combobox__option) are in the HTML, with data-value, an optional swatch or icon and a check mark.
    • .combobox__empty, .combobox__reset ("Clear all") and a live region complete it.

    Keyboard

    It follows the WAI-ARIA Combobox pattern with a list popup. The focus stays in the input; the highlighted option is set with aria-activedescendant.

    KeyAction
    TypingFilters the options and opens the list.
    ↓ / ↑Opens the list and moves the highlight (wraps around). Alt + ↓ only opens it.
    EnterSelects or deselects the highlighted option; the list stays open for more.
    BackspaceIn an empty input, removes the last selected option.
    EscCloses the list; pressed again, clears the search text.
    TabCloses the list and moves on.

    Accessibility

    • A polite live region announces the number of results while typing, and every selection or removal ("Blue selected.").
    • The chips' remove buttons have a visually hidden label ("Remove Blue") and return the focus to the input.
    • The selected options keep aria-selected="true" and a check mark, so the state isn't communicated by color only.

    Technical Details

    • Vanilla JavaScript, no dependencies; the options come from the markup, so you can render them on the server.
    • The hidden inputs are submitted with the form like a select multiple. A combobox:change event (with the selected values) fires on every change.
    • The chevron is an element positioned with inset-inline-end, so it flips in RTL and follows the color mode (a background image can't).
    • The colors come from the tokens; the old combobox color group is no longer needed.
    • For a single choice, use the Autocomplete component.

    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.