Combobox
A Tailwind CSS combobox component that combines a trigger button with a searchable, keyboard-navigable list. Supports single and multi-select.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-framework"
aria-expanded="false"
>
<span class="combobox-value">Select a framework...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-framework" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search framework..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework" value="next" />
Next.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework" value="sveltekit" />
SvelteKit
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework" value="nuxt" />
Nuxt.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework" value="remix" />
Remix
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework" value="astro" />
Astro
</div>
</div>
<div class="combobox-empty">No framework found.</div>
</div>With label
Pair with a Label and wrap in a Field for accessible form fields. Use aria-labelledby on the trigger pointing to the label's id so screen readers announce it correctly.
<div class="w-fit">
<div class="field">
<label class="label" id="framework-label">Framework</label>
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-labelled"
aria-expanded="false"
aria-labelledby="framework-label"
>
<span class="combobox-value">Select a framework...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-labelled" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search framework..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework-labelled" value="next" />
Next.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework-labelled" value="sveltekit" />
SvelteKit
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework-labelled" value="nuxt" />
Nuxt.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework-labelled" value="remix" />
Remix
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="framework-labelled" value="astro" />
Astro
</div>
</div>
<div class="combobox-empty">No framework found.</div>
</div>
</div>
</div>Single select
Use <input type="radio"> inside each item for single select. Clicking an item checks the radio, updates the trigger text, and closes the menu. Only one item can be selected at a time.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-single"
aria-expanded="false"
>
<span class="combobox-value">Select a framework...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-single" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search framework..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="single-framework" value="next" />
Next.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="single-framework" value="sveltekit" />
SvelteKit
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="single-framework" value="nuxt" />
Nuxt.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="single-framework" value="remix" />
Remix
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="single-framework" value="astro" />
Astro
</div>
</div>
<div class="combobox-empty">No framework found.</div>
</div>Multi-select
Use <input type="checkbox"> inside each item for multi-select. The menu stays open after selection, and the trigger shows "N name selected" for two or more selections.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-multi"
aria-expanded="false"
>
<span class="combobox-value">Select tools...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-multi" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search tools..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="checkbox" class="sr-only" tabindex="-1" name="tools" value="eslint" />
ESLint
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="checkbox" class="sr-only" tabindex="-1" name="tools" value="prettier" />
Prettier
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="checkbox" class="sr-only" tabindex="-1" name="tools" value="typescript" />
TypeScript
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="checkbox" class="sr-only" tabindex="-1" name="tools" value="vitest" />
Vitest
</div>
</div>
<div class="combobox-empty">No tools found.</div>
</div>Pre-selected value
Add checked to the input and aria-selected="true" to the item. Set the trigger text to the selected label and store the placeholder in data-placeholder.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-preselected"
aria-expanded="false"
>
<span class="combobox-value" data-placeholder="Select a framework...">
SvelteKit
</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-preselected" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search framework..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="preselected-framework" value="next" />
Next.js
</div>
<div class="combobox-item" role="option" tabindex="0" aria-selected="true">
<input type="radio" class="sr-only" tabindex="-1" name="preselected-framework" checked="" value="sveltekit" />
SvelteKit
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="preselected-framework" value="nuxt" />
Nuxt.js
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="preselected-framework" value="remix" />
Remix
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="preselected-framework" value="astro" />
Astro
</div>
</div>
<div class="combobox-empty">No framework found.</div>
</div>With labels
Use .combobox-label and .combobox-separator to group options visually.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-grouped"
aria-expanded="false"
>
<span class="combobox-value">Select a language...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-grouped" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search..." />
</div>
<div class="combobox-list">
<div class="combobox-label">Frontend</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="language" value="ts" />
TypeScript
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="language" value="js" />
JavaScript
</div>
<div class="combobox-separator"></div>
<div class="combobox-label">Backend</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="language" value="go" />
Go
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="language" value="rust" />
Rust
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="language" value="python" />
Python
</div>
</div>
<div class="combobox-empty">No language found.</div>
</div>Disabled items
Use aria-disabled="true" and tabindex="-1" on a .combobox-item to prevent selection. Add disabled to the input as well.
<button
class="combobox-trigger w-56"
type="button"
data-sp-toggle="combobox"
data-sp-target="#combobox-disabled"
aria-expanded="false"
>
<span class="combobox-value">Select a plan...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="combobox-disabled" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search plans..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="plan" value="free" />
Free
</div>
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" class="sr-only" tabindex="-1" name="plan" value="pro" />
Pro
</div>
<div class="combobox-item" role="option" tabindex="-1" aria-disabled="true">
<input type="radio" class="sr-only" tabindex="-1" disabled="" name="plan" value="enterprise" />
Enterprise
</div>
</div>
<div class="combobox-empty">No plan found.</div>
</div>How it works
Structure
A combobox consists of two elements linked by id:
.combobox-triggerwithdata-sp-toggle="combobox"anddata-sp-target="#id"— the trigger button containing a.combobox-valuespan.comboboxwith a matchingid— the floating panel with search input and item list
Each item contains a visually hidden <input type="radio"> (single select) or <input type="checkbox"> (multi-select) with a name and value for form submission.
<button
class="combobox-trigger"
type="button"
data-sp-toggle="combobox"
data-sp-target="#my-combobox"
aria-expanded="false"
>
<span class="combobox-value">Select an option...</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>
</button>
<div id="my-combobox" class="combobox" role="listbox">
<div class="combobox-search">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
<input class="combobox-input" type="text" placeholder="Search..." />
</div>
<div class="combobox-list">
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" name="option" value="a" class="sr-only" tabindex="-1" />
Option A
</div>
</div>
<div class="combobox-empty">No results found.</div>
</div>The trigger and the menu can live anywhere in the DOM as long as the id matches.
Opening and closing
Add data-sp-toggle="combobox" and data-sp-target to the trigger to toggle the linked menu on click. Clicking outside the combobox or pressing Escape closes the menu. For single select, clicking an item selects it and closes the menu. For multi-select, the menu stays open. The search input is cleared and items are reset when the menu closes.
For programmatic control, use the global sp.combobox module:
const trigger = document.querySelector("[data-sp-target='#my-combobox']");
const menu = document.getElementById("my-combobox");
const item = menu.querySelector(".combobox-item");
sp.combobox.open(trigger);
sp.combobox.close(menu);
sp.combobox.toggle(trigger);
sp.combobox.select(trigger, menu, item);
sp.combobox.filter(menu, "query");Filtering
Typing in the search input automatically hides items whose text content does not match the query (case-insensitive). The .combobox-empty element becomes visible when no items match.
To filter programmatically, call sp.combobox.filter:
const menu = document.getElementById("my-combobox");
sp.combobox.filter(menu, "next");Selection
When an item is selected, the module checks the item's radio or checkbox input and updates aria-selected on the item. The .combobox-value text is updated with the selected label.
A native change event is dispatched from the input and bubbles up through the DOM. Listen for it on the menu, a parent form, or any ancestor:
document.getElementById("my-combobox").addEventListener("change", (e) => {
console.log(e.target.name, e.target.value, e.target.checked);
});Placeholder
The .combobox-value span shows muted text when no data-placeholder attribute is present (the default state). When a selection is made, the original text is stored in data-placeholder and the trigger text updates to the selected label.
For pre-selected values, set data-placeholder to the placeholder text and the trigger text to the selected label:
<span class="combobox-value" data-placeholder="Select a framework...">
SvelteKit
</span>Animation
The combobox menu includes a default fade and zoom entrance animation. When opened, the JavaScript sets data-state="open" on the menu.
To disable the default animation and apply your own, add no-animation to the menu:
<div
id="my-combobox"
class="combobox no-animation data-[state=open]:animate-in data-[state=open]:slide-in-from-top-2"
>
...
</div>Placement
Use data-sp-placement on the trigger to control where the menu appears relative to it (default is bottom-start). Use data-sp-offset to set the distance in pixels (default is 4).
<button
class="combobox-trigger"
data-sp-toggle="combobox"
data-sp-target="#my-combobox"
data-sp-placement="bottom-end"
data-sp-offset="8"
>
...
</button>Supported placement values follow the Floating UI convention: top, top-start, top-end, bottom, bottom-start, bottom-end, left, left-start, left-end, right, right-start, right-end.
Accessibility
Add aria-expanded="false" to the trigger button. It toggles to "true" when the menu opens. Items should have role="option" and tabindex="0" so they can receive keyboard focus.
<button
class="combobox-trigger"
data-sp-toggle="combobox"
data-sp-target="#my-combobox"
aria-expanded="false"
>
...
</button>
...
<div class="combobox-item" role="option" tabindex="0">
<input type="radio" name="option" value="a" class="sr-only" tabindex="-1" />
Option A
</div>Keyboard navigation
| Key | Action |
|---|---|
Enter / Space | Opens the menu when trigger is focused |
Escape | Closes the menu and returns focus to the trigger |
ArrowDown | Move focus to the next visible item |
ArrowUp | Move focus to the previous visible item |
Home | Move focus to the first visible item |
End | Move focus to the last visible item |
Enter / Space | Selects the focused item |
Class reference
| Class | Description |
|---|---|
combobox | Floating panel containing the search input and list |
combobox-trigger | Button that opens and closes the menu |
combobox-value | Span inside the trigger for the selected label or placeholder text |
combobox-search | Row containing the search icon and input |
combobox-input | Borderless text input for filtering options |
combobox-list | Scrollable container for the list of options |
combobox-item | Individual selectable option |
combobox-label | Non-interactive group heading |
combobox-separator | Horizontal divider between groups |
combobox-empty | Message shown when no items match the search query |
no-animation | Add to .combobox to disable the default open animation |
Data attributes
| Attribute | Element | Description |
|---|---|---|
data-sp-toggle="combobox" | Trigger button | Marks the trigger |
data-sp-target="#id" | Trigger button | The id of the linked .combobox menu |
data-sp-placement | Trigger button | Menu placement relative to trigger (default: bottom-start) |
data-sp-offset | Trigger button | Distance from trigger in pixels (default: 4) |
aria-expanded | Trigger button | Set "false" initially; JS toggles on open/close |
data-placeholder | .combobox-value | Stores the original placeholder text after first selection |
data-state | .combobox | Set to open when menu is visible |
aria-disabled="true" | .combobox-item | Disables the item visually and functionally |
aria-selected="true" | .combobox-item | Marks the item as currently selected |