Design.dev design.dev
UI Component Prompt

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.

Copy → Paste → Ship

Live Preview

Vanilla JS · Zero Dependencies · ARIA Combobox

Type to filter across groups, arrow keys to move, Enter to select.

Click to add or remove. Backspace inside the trigger removes the last chip.

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

Anatomy

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

Frequently Asked Questions

What is the difference between a combobox, a select, and an autocomplete?

A native <select> is a closed list the user picks from with no typing. An autocomplete is a free-text input that suggests completions but does not require choosing one. A combobox is the hybrid — a text input bound to a listbox of valid options where the user can type to filter and must commit to one (or more) of the listed values. The component on this page implements the ARIA 1.2 combobox pattern.

How do I build a multi-select dropdown without a library like Select2 or React-Select?

Render the trigger as an element with role="combobox", show selected values as removable chip elements inside that trigger, and toggle a panel containing a search input and role="listbox" of options. Each option uses role="option" with aria-selected reflecting state. Track an array of selected IDs in component state, and on Enter or click, add or remove from that array instead of closing the panel. The prompt above outputs the full vanilla-JavaScript implementation.

Does it follow the WAI-ARIA combobox pattern?

Yes. The trigger is role="combobox" with aria-haspopup="listbox", aria-expanded mirroring open state, and aria-controls pointing at the listbox ID. Active descendant tracking via aria-activedescendant means focus stays in the search input while keyboard navigation visually highlights the active option — exactly the pattern recommended by the WAI-ARIA Authoring Practices Guide.

Will form submission work with this custom dropdown?

Yes. The component progressively enhances a hidden native <select> (or hidden inputs for multi-select) so a standard form submit posts the selected values without any extra JavaScript. This means the dropdown works with traditional server-rendered forms, FormData APIs, and modern frameworks alike.

How do I handle large lists of options (1,000+) performantly?

The prompt's filter function is O(n) and string-normalized, which handles a few thousand options with no perceptible lag. For 10,000+ options, switch the listbox to a virtualized renderer that only mounts the visible rows on scroll — the component's option model is decoupled from rendering precisely so this swap is straightforward.

What keyboard shortcuts are supported?

Arrow Down opens the panel and moves to the next option; Arrow Up moves to the previous; Home and End jump to the first or last option; typing in the search input filters options as you go; Enter selects the active option (in multi-select, toggles it without closing); Escape closes the panel and returns focus to the trigger; Tab moves focus naturally to the next form field.

Can I use this combobox with React, Vue, or Svelte?

Yes. The output is framework-agnostic vanilla JavaScript exposing a SearchableDropdown class. In React, instantiate inside useEffect with a ref. In Vue, instantiate inside onMounted. In Svelte, use onMount. The class exposes methods like setValue(values), getValue(), openPanel(), closePanel(), toggle(), and clear(). Always remove outside-click and resize listeners on unmount to avoid memory leaks.

Copied to clipboard