Design.dev design.dev
Forms · Component Template

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.

View prompt

Live demo

Vanilla JS · Zero Dependencies · ARIA Combobox

Two instances, one component — switched by a single multiple: true option. Both share the same keyboard model and ARIA semantics.

The Prompt 972 words

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
  1. CopyThe full prompt, one click.
  2. PasteInto Cursor, Claude Code, or any agent.
  3. CompareAgainst the live demo above.

What’s Inside

01

Trigger Button

The combobox surface — native button element with role="combobox", aria-haspopup="listbox", and aria-expanded.

02

Removable Chips

Multi-select selections render as pill tokens inside the trigger, each with its own labeled remove button.

03

Auto-Focused Search

Opening the panel jumps focus into the filter input so keyboard users can start typing instantly.

04

Highlighted Matches

Matching characters in option labels are wrapped in <mark> and rendered in cyan so users see exactly what their query hit.

05

Grouped Options

Uppercase section labels split the listbox into categories — whole groups hide when their options are filtered out.

06

Active Descendant

DOM focus stays on the search input; aria-activedescendant moves to the highlighted option for AT compatibility.

07

Backspace-to-Remove

In multi mode, backspace on an empty search input pops the last chip — the gesture every power user expects.

08

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
Searchable Dropdown
Copied to clipboard