Combobox
A searchable select triggered by an outline button. Opens a popover with a search input and a scrollable list of options.
Keyboard navigable via aria-activedescendant. Uses the popover API for top-layer rendering.
Basic Combobox
An outline button opens a popover with a search field and selectable options. Click the trigger or use keyboard to interact.
<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.
<!-- 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.
<!-- 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 });