Basic Combobox

An outline button opens a popover with a search field and selectable options. Click the trigger or use keyboard to interact.

Next.js
SvelteKit
Nuxt
Remix
Astro
<div class="combobox" style="width:14rem;">
  <label class="label" id="framework-label">Framework</label>
  <button class="btn combobox-trigger" data-variant="outline"
          aria-haspopup="listbox"
          aria-expanded="false"
          aria-labelledby="framework-label"
          aria-controls="framework-popover">
    <span class="combobox-value"
          data-placeholder="Select framework...">
      Select framework...
    </span>
    <svg class="combobox-chevron" aria-hidden="true"
         width="16" height="16" viewBox="0 0 24 24"
         fill="none" stroke="currentColor" stroke-width="2">
      <path d="m7 15 5 5 5-5"/>
      <path d="m7 9 5-5 5 5"/>
    </svg>
  </button>
  <div id="framework-popover"
       class="combobox-content" popover>
    <div class="combobox-search">
      <svg class="combobox-search-icon" ...>...</svg>
      <input class="combobox-search-input"
             type="text" role="combobox"
             autocomplete="off"
             aria-controls="framework-listbox"
             aria-autocomplete="list"
             placeholder="Search...">
    </div>
    <div id="framework-listbox"
         role="listbox" class="combobox-listbox">
      <div class="combobox-empty" hidden>
        No results found.
      </div>
      <div role="option" class="combobox-item"
           data-value="nextjs">Next.js</div>
      <!-- more options... -->
    </div>
  </div>
</div>

Grouped with Disabled Items

Combobox with option groups, visual separators, and a disabled option. Use .combobox-group-label for headings and aria-disabled="true" to disable individual items.

North America
Eastern (EST)
Central (CST)
Pacific (PST)
Europe
GMT (London)
CET (Berlin)
EET (Bucharest)
<!-- Groups use .combobox-group-label and .combobox-separator -->
<div class="combobox-group-label">North America</div>
<div role="option" class="combobox-item"
     data-value="est">Eastern (EST)</div>
<div class="combobox-separator"></div>
<div class="combobox-group-label">Europe</div>
<div role="option" class="combobox-item"
     data-value="gmt">GMT (London)</div>

<!-- Disabled option -->
<div role="option" class="combobox-item"
     aria-disabled="true">EET (Bucharest)</div>

Disabled

Disable the trigger button to prevent interaction.

Next.js
SvelteKit
<!-- Add disabled to the trigger button -->
<button class="btn combobox-trigger"
        data-variant="outline" disabled
        aria-haspopup="listbox"
        aria-expanded="false">
  ...
</button>

CSS view file

/* -- Combobox component ---------------------------------------- */

@layer components {
  .combobox {
    position: relative;
  }

  .combobox-trigger {
    width: 100%;
    justify-content: space-between;
  }

  .combobox-value {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    flex: 1;
    text-align: left;
    font-weight: 400;
  }

  .combobox-value[data-placeholder] {
    color: var(--muted-foreground);
  }

  .combobox-chevron {
    flex-shrink: 0;
    color: var(--muted-foreground);
    opacity: 0.5;
  }

  .combobox-content {
    position: fixed;
    inset: auto;
    margin: 0;
    padding: 0;
    background-color: var(--popover);
    color: var(--popover-foreground);
    border: 1px solid var(--border);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-md);
    overflow: hidden;

    /* -- Anchor positioning -- */
    top: anchor(bottom);
    left: anchor(left);
    width: anchor-size(width);
    margin-top: 4px;
    position-try-fallbacks: flip-block;

    /* -- Animation --------------------------------------------- */
    opacity: 0;
    transform: scale(0.96) translateY(-0.25rem);
    transition: opacity 150ms ease, transform 150ms ease,
                display 150ms allow-discrete;

    &:popover-open {
      opacity: 1;
      transform: scale(1) translateY(0);
    }
  }

  @starting-style {
    .combobox-content:popover-open {
      opacity: 0;
      transform: scale(0.96) translateY(-0.25rem);
    }
  }

  .combobox-search {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.5rem 0.75rem;
    border-bottom: 1px solid var(--border);
  }

  .combobox-search-icon {
    flex-shrink: 0;
    color: var(--muted-foreground);
  }

  .combobox-search-input {
    width: 100%;
    border: none;
    background: transparent;
    font-size: 0.875rem;
    font-family: var(--font-sans);
    color: var(--foreground);
    outline: none;

    &::placeholder { color: var(--muted-foreground); }
  }

  .combobox-listbox {
    max-height: 16rem;
    overflow-y: auto;
    overscroll-behavior: contain;
    padding: 0.25rem;
  }

  .combobox-item {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 0.375rem 0.5rem;
    border-radius: calc(var(--radius) * 0.6);
    font-size: 0.875rem;
    cursor: pointer;
    outline: none;
    transition: background 100ms;

    &:hover, &[data-highlighted] {
      background-color: var(--accent);
      color: var(--accent-foreground);
    }

    &[aria-selected="true"]::before {
      content: '';
      display: inline-block;
      width: 1rem;
      height: 1rem;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
      background-size: contain;
      flex-shrink: 0;
    }

    &[aria-disabled="true"] {
      pointer-events: none;
      opacity: 0.5;
    }
  }

  .combobox-empty {
    padding: 1.5rem 0.5rem;
    text-align: center;
    font-size: 0.875rem;
    color: var(--muted-foreground);
  }

  .combobox-group-label {
    padding: 0.375rem 0.5rem;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--muted-foreground);
  }

  .combobox-separator {
    height: 1px;
    background: var(--border);
    margin: 0.25rem -0.25rem;
  }

  /* -- Accessibility ------------------------------------------ */

  @media (prefers-reduced-motion: reduce) {
    .combobox-content {
      transition: none;
    }
    .combobox-item {
      transition: none;
    }
  }

  @media (forced-colors: active) {
    .combobox-content {
      border-color: ButtonText;
    }
    .combobox-item {
      &:hover, &[data-highlighted] {
        forced-color-adjust: none;
        background: Highlight;
        color: HighlightText;
      }
    }
  }
}

JavaScript view file

Trigger opens/closes the popover. Search input filters the list. Arrow keys navigate, Enter selects, Escape closes. Focus moves to search input on open and back to trigger on close.

// -- Combobox -------------------------------------------------
// Searchable select with keyboard navigation and popover positioning.

function init() {
  document.querySelectorAll('.combobox:not([data-init])').forEach((wrapper) => {
    wrapper.dataset.init = '';
    const trigger = wrapper.querySelector('.combobox-trigger');
    const valueEl = wrapper.querySelector('.combobox-value');
    const popover = wrapper.querySelector('.combobox-content');
    const searchInput = wrapper.querySelector('.combobox-search-input');
    const listbox = wrapper.querySelector('[role="listbox"]');
    const empty = wrapper.querySelector('.combobox-empty');
    if (!trigger || !popover || !searchInput || !listbox) return;

    const allItems = Array.from(listbox.querySelectorAll('[role="option"]'));
    let highlighted = -1;

    // CSS anchor positioning - unique name per trigger-popover pair
    const anchorId = `--combobox-${popover.id}`;
    trigger.style.anchorName = anchorId;
    popover.style.positionAnchor = anchorId;

    const getVisibleItems = () => allItems.filter((item) => !item.hidden && item.getAttribute('aria-disabled') !== 'true');
    const open = () => {
      popover.showPopover();
      trigger.setAttribute('aria-expanded', 'true');
      searchInput.value = '';
      filter('');
      searchInput.focus();
    };
    const close = () => {
      popover.hidePopover();
      trigger.setAttribute('aria-expanded', 'false');
      searchInput.setAttribute('aria-activedescendant', '');
      clearHighlight();
      trigger.focus();
    };
    const isOpen = () => popover.matches(':popover-open');
    const filter = (query) => {
      const q = query.toLowerCase(); let hasVisible = false;
      allItems.forEach((item) => { const match = !q || item.textContent.trim().toLowerCase().includes(q); item.hidden = !match; if (match) hasVisible = true; });
      listbox.querySelectorAll('.combobox-group-label').forEach((label) => {
        let next = label.nextElementSibling; let groupHasVisible = false;
        while (next && !next.classList.contains('combobox-group-label') && !next.classList.contains('combobox-separator')) {
          if (next.getAttribute('role') === 'option' && !next.hidden) groupHasVisible = true; next = next.nextElementSibling;
        }
        label.hidden = !groupHasVisible;
      });
      listbox.querySelectorAll('.combobox-separator').forEach((sep) => { const prev = sep.previousElementSibling; const next = sep.nextElementSibling; sep.hidden = (prev && prev.hidden) || (next && next.hidden); });
      if (empty) empty.hidden = hasVisible;
    };
    const clearHighlight = () => { allItems.forEach((item) => { delete item.dataset.highlighted; }); highlighted = -1; };
    const doHighlight = (index) => {
      const items = getVisibleItems(); clearHighlight();
      if (index < 0 || index >= items.length) return;
      highlighted = index; items[index].dataset.highlighted = '';
      items[index].scrollIntoView({ block: 'nearest' });
      searchInput.setAttribute('aria-activedescendant', items[index].id);
    };
    const selectItem = (item) => {
      if (item.getAttribute('aria-disabled') === 'true') return;
      allItems.forEach((i) => { i.setAttribute('aria-selected', 'false'); });
      item.setAttribute('aria-selected', 'true');
      if (valueEl) { valueEl.textContent = item.textContent.trim(); valueEl.removeAttribute('data-placeholder'); }
      close();
    };
    trigger.addEventListener('click', () => { if (isOpen()) { close(); } else { open(); } });
    searchInput.addEventListener('input', () => { filter(searchInput.value); doHighlight(0); });
    searchInput.addEventListener('keydown', (e) => {
      const items = getVisibleItems();
      switch (e.key) {
        case 'ArrowDown': e.preventDefault(); doHighlight(Math.min(highlighted + 1, items.length - 1)); break;
        case 'ArrowUp': e.preventDefault(); doHighlight(Math.max(highlighted - 1, 0)); break;
        case 'Home': e.preventDefault(); doHighlight(0); break;
        case 'End': e.preventDefault(); doHighlight(items.length - 1); break;
        case 'Enter': e.preventDefault(); if (highlighted >= 0 && items[highlighted]) selectItem(items[highlighted]); break;
        case 'Escape': e.preventDefault(); close(); break;
        case 'Tab': close(); break;
      }
    });
    listbox.addEventListener('click', (e) => { const item = e.target.closest('[role="option"]'); if (item && !item.hidden && item.getAttribute('aria-disabled') !== 'true') selectItem(item); });
    listbox.addEventListener('mousemove', (e) => { const item = e.target.closest('[role="option"]'); if (item && !item.hidden) { const items = getVisibleItems(); doHighlight(items.indexOf(item)); } });
    popover.addEventListener('toggle', (e) => { if (e.newState === 'closed') { trigger.setAttribute('aria-expanded', 'false'); clearHighlight(); } });
  });
}

init();
new MutationObserver(init).observe(document, { childList: true, subtree: true });