Searchable Dropdown
A custom select with type-to-filter search, single and multi-select modes, removable chips, and the full ARIA combobox keyboard contract. Copy the prompt, paste it, ship it.
Live demo
Two instances, one component — switched by a single multiple: true option. Both share the same keyboard model and ARIA semantics.
The Prompt
Build a searchable dropdown / combobox component using vanilla HTML, CSS, and JavaScript. No libraries, no frameworks — zero dependencies. Must support BOTH single-select and multi-select modes from the same class.
VISUAL DESIGN
- Trigger button: full-width, 42px min-height, border-radius 10px, 1px border at rgba(255,255,255,0.1), background rgba(255,255,255,0.04)
- Trigger states: hover brightens border and background; open state and :focus-visible add a cyan border (#00c4ff), pale cyan background tint, and a 4px cyan glow ring
- Chevron icon (14px) on the right that rotates 180° and recolors to cyan when open
- Single-select trigger shows the selected label as plain text; placeholder text in rgba(255,255,255,0.35) when empty
- Multi-select trigger shows selected items as removable pill chips: 12px font, padding 3px 4px 3px 9px, border-radius 999px, cyan background tint with cyan border, X button on the right of each chip
- Optional clear button (×) on the trigger when something is selected
- Dropdown panel: position absolute below trigger, 6px gap, background rgba(22,22,26,0.98) with backdrop-blur, 1px border at rgba(255,255,255,0.1), border-radius 12px, heavy box-shadow (0 20px 60px rgba(0,0,0,0.5))
- Search input at the top of the panel with magnifying-glass icon, 13px font, transparent background
- Options list: 8px padding rows with 6px border-radius, hover state at rgba(255,255,255,0.04), keyboard-active state at rgba(0,196,255,0.08)
- Selected options show a cyan checkmark on the left (16px space reserved even when empty so labels never shift)
- Group labels: uppercase 10px at rgba(255,255,255,0.35) with letter-spacing
- Empty state: "No matches" centered in the list area
- Footer with keyboard hints in tiny monospace badges (optional but nice)
ENTER ANIMATION
- Panel scales from 0.98 + translateY(-4px) to 1.0 over 160ms cubic-bezier(0.16, 1, 0.3, 1)
- prefers-reduced-motion: skip animations entirely
KEYBOARD CONTRACT (full WAI-ARIA combobox)
- Closed state, trigger focused:
- ArrowDown / Enter / Space: open the panel and focus the search input; first option becomes the active descendant
- ArrowUp: open the panel with the LAST option active
- Open state, search input focused:
- ArrowDown: move active option down (skip disabled, wrap at end)
- ArrowUp: move active option up (wrap at start)
- Home: jump to first option
- End: jump to last option
- PageDown / PageUp: jump 8 options
- Enter: select the active option (multi: toggle and KEEP open; single: select and CLOSE)
- Escape: close panel, restore focus to trigger, do not change selection
- Tab: close panel and let focus move naturally
- Backspace on empty search input (multi mode only): remove the last selected chip
- Type to filter is the default (search input is auto-focused on open) — no separate type-ahead needed
MARKUP NOTE (important)
- The trigger MUST be a <div> with role="combobox" and tabindex="0", NOT a <button> element. Removable chip × buttons and the clear-all button live inside the trigger; the HTML5 parser closes any <button> the moment it sees a nested <button> start tag, which would dump those controls outside the trigger as siblings. A focusable div with role="combobox" is the standard WAI-ARIA pattern for this exact reason.
- Handle Enter / Space / ArrowDown / ArrowUp on the trigger via keydown (since divs don't activate on Space/Enter natively).
ARIA SEMANTICS
- Trigger: role="combobox", aria-haspopup="listbox", aria-expanded reflects open state, aria-controls points to the listbox id, aria-labelledby points to a visible <label>
- Search input inherits combobox semantics or stands as the active element (your call — both are spec-compliant in ARIA 1.2)
- Listbox (<ul>): role="listbox", aria-multiselectable matches mode
- Each option (<li>): role="option", unique id, aria-selected="true|false", aria-disabled when applicable
- Active descendant pattern: keep DOM focus on the search input; set aria-activedescendant on it to the active option's id
- Removable chips: each X button has aria-label="Remove [label]"
- Group labels: role="presentation" or use proper aria-labelledby on subgroups
SEARCH / FILTERING
- Case-insensitive substring match against option label (or against a custom searchable text field)
- Highlight matched substring in the rendered label using <mark> tags styled with no background and bold cyan text
- Filtering hides whole groups whose options are all filtered out
- Empty result set shows the "No matches" empty state
- Reset filter when the panel closes
INTERACTION DETAILS
- Click outside (pointerdown anywhere outside the .sd root): close the panel, do not change selection
- Clicking the trigger when open: close the panel
- Clicking an option: same as Enter on the active option
- Hovering an option (mousemove): set it as the active descendant (no auto-scroll)
- Active option always scrollIntoView({ block: 'nearest' }) when changed via keyboard
- Multi-select with many chips: trigger min-height grows to fit; chips wrap to a new line gracefully
API (the class)
- new SearchableDropdown(rootElement, {
options: [{ value, label, group?, disabled? }],
multiple: false,
placeholder: 'Select...',
searchPlaceholder: 'Search...',
initial: [], // array of values
onChange: (values) => {} // fires with array of selected values (single mode also returns array of length 0 or 1)
})
- Public methods: getValue(), setValue(values), clear(), open(), close(), destroy(), refresh(options)
- Dispatches a 'change' CustomEvent on the root element with { detail: { value } }
- Hidden <select> mirror inside the root for native form submission compatibility (optional but recommended); single-select uses <select>, multi uses <select multiple>
ACCESSIBILITY
- Auto-focus the search input on open so keyboard users land in a useful place
- prefers-reduced-motion: disable open animation and chevron rotation
- Maintain a 4.5:1 contrast ratio for label and option text against the panel background
- Touch targets: trigger and options are at least 36px tall
OUTPUT
- Single HTML file with embedded <style> and <script>
- TWO live demos in the file: one single-select with grouped options, one multi-select with chips
- Both should share the same SearchableDropdown class — only the multiple: true option differs
- Polished, professional aesthetic — should feel like the dropdowns in a premium modern developer-tool dashboard
- CopyThe full prompt, one click.
- PasteInto Cursor, Claude Code, or any agent.
- CompareAgainst the live demo above.
What’s Inside
Trigger Button
The combobox surface — native button element with role="combobox", aria-haspopup="listbox", and aria-expanded.
Removable Chips
Multi-select selections render as pill tokens inside the trigger, each with its own labeled remove button.
Auto-Focused Search
Opening the panel jumps focus into the filter input so keyboard users can start typing instantly.
Highlighted Matches
Matching characters in option labels are wrapped in <mark> and rendered in cyan so users see exactly what their query hit.
Grouped Options
Uppercase section labels split the listbox into categories — whole groups hide when their options are filtered out.
Active Descendant
DOM focus stays on the search input; aria-activedescendant moves to the highlighted option for AT compatibility.
Backspace-to-Remove
In multi mode, backspace on an empty search input pops the last chip — the gesture every power user expects.
Hidden Native Mirror
An invisible <select> inside the root keeps form submission, autofill, and server-side handlers working unchanged.
Usage Guidelines
Use This When
- The list of options is too long to scan visually — 10+ items where users need search to find what they want
- You need a multi-select tag picker for skills, labels, assignees, categories, or filters
- The native <select> is too restrictive: you want grouped options, custom rendering, or chip-style selections that survive the back button
Not Ideal When
- You only have 2–5 options — radio buttons, segmented controls, or the native select are simpler and more accessible by default
- The choice is binary (yes/no, on/off) — use a toggle switch or checkbox
- The user is on a mobile device and the native select's full-screen wheel picker is genuinely better UX for the dataset