A searchable dropdown is a text input that filters a list of options as the user types. This guide builds one in plain JavaScript that loads its options from an API, handles thousands of items, works with the keyboard, and is announced correctly by screen readers.
Quick answer: if you only need suggestions and don't care how the list looks, HTML already has one:
<label for="fruit">Fruit</label>
<input id="fruit" list="fruits">
<datalist id="fruits">
<option value="Apple">
<option value="Banana">
<option value="Cherry">
</datalist>The browser decides how <datalist> looks and matches, and you can't style the options or highlight matches. Build your own when you need a consistent design, custom rendering, or results from a server — which is what the rest of this guide does.
Tested in: Chrome 152 on localhost, October 2026, against the DummyJSON users API.
What We'll Build
- Case-insensitive filtering, with the matching text in bold.
- Options loaded from an API once, or searched on the server as you type.
- Arrow keys, Enter and Escape; mouse selection that doesn't fight the input's blur.
- A No results state and an error state when the request fails.
- ARIA combobox roles so screen readers announce the list and the highlighted option.
- Fast with 100,000 items, because it never renders more than 50 options.
1. The HTML
<label for="user-input">User</label>
<div class="combo">
<input id="user-input" type="text" role="combobox" autocomplete="off"
aria-autocomplete="list" aria-expanded="false" aria-controls="user-list"
placeholder="Type a name...">
<ul id="user-list" role="listbox" aria-label="Users" hidden></ul>
</div>The ARIA attributes follow the WAI-ARIA combobox pattern:
- role="combobox" and aria-controls tell assistive technology the input controls the list.
- aria-expanded says whether the list is open. The script keeps it in sync.
- aria-activedescendant (set by the script) points at the highlighted option, so focus stays in the input while screen readers announce the option.
- autocomplete="off" stops the browser's own autofill list from covering yours.
2. The CSS
.combo {
position: relative;
width: 300px;
}
.combo input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
.combo [role="listbox"] {
position: absolute;
z-index: 10;
width: 100%;
max-height: 240px;
overflow-y: auto;
margin: 4px 0 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
}
.combo [role="option"] {
padding: 8px;
cursor: pointer;
}
.combo [role="option"]:hover,
.combo [role="option"][aria-selected="true"] {
background: #e8f0fe;
}
.combo .empty {
color: #666;
cursor: default;
}
.combo mark {
background: none;
font-weight: 600;
color: inherit;
}The list is position: absolute inside a position: relative wrapper, so it opens below the input without pushing the page down. The highlighted option is styled through [aria-selected="true"] — the same attribute screen readers use — so there's no separate CSS class to keep in sync.
3. Filtering the Options
function filterItems(items, query, maxResults = 50) {
const q = query.trim().toLowerCase();
const matches = [];
for (const item of items) {
if (item.toLowerCase().includes(q)) {
matches.push(item);
if (matches.length === maxResults) break;
}
}
return matches;
}It matches anywhere in the text, ignores case and surrounding spaces, and stops after maxResults matches. That limit matters more than any other optimisation: nobody scrolls through 5,000 suggestions, and creating thousands of list items is what makes a dropdown feel slow (see the numbers in the performance section).
4. Highlighting the Match
function appendHighlighted(el, text, query) {
const q = query.trim().toLowerCase();
const start = q ? text.toLowerCase().indexOf(q) : -1;
if (start === -1) {
el.textContent = text;
return;
}
const mark = document.createElement("mark");
mark.textContent = text.slice(start, start + q.length);
el.append(text.slice(0, start), mark, text.slice(start + q.length));
}This builds the option from text nodes and a <mark> element. Don't build it with innerHTML and a regex: an option such as <img src=x onerror=alert(1)> coming from an API would run as code.
5. The Dropdown Component
This function owns the open/close state, keyboard handling and rendering. It doesn't know where the options come from; you call show(items, query) whenever you have new results.
function createDropdown(input, list, { onSelect } = {}) {
let options = [];
let activeIndex = -1;
function open() {
list.hidden = false;
input.setAttribute("aria-expanded", "true");
}
function close() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
activeIndex = -1;
}
function setActive(index) {
const items = list.querySelectorAll('[role="option"]:not([aria-disabled])');
items.forEach((li) => li.setAttribute("aria-selected", "false"));
activeIndex = index;
const li = items[index];
li.setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", li.id);
li.scrollIntoView({ block: "nearest" });
}
function choose(index) {
input.value = options[index];
close();
onSelect?.(options[index]);
}
function show(items, query, message = "No results") {
options = items;
activeIndex = -1;
input.removeAttribute("aria-activedescendant");
list.replaceChildren();
if (items.length === 0) {
const li = document.createElement("li");
li.setAttribute("role", "option");
li.setAttribute("aria-disabled", "true");
li.className = "empty";
li.textContent = message;
list.append(li);
}
items.forEach((item, i) => {
const li = document.createElement("li");
li.id = `${list.id}-option-${i}`;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", "false");
appendHighlighted(li, item, query);
// mousedown + preventDefault keeps focus in the input, so blur
// doesn't close the list before the click lands
li.addEventListener("mousedown", (e) => {
e.preventDefault();
choose(i);
});
list.append(li);
});
open();
}
input.addEventListener("keydown", (e) => {
const count = options.length;
if (e.key === "ArrowDown" && count > 0 && !list.hidden) {
e.preventDefault();
setActive((activeIndex + 1) % count);
} else if (e.key === "ArrowUp" && count > 0 && !list.hidden) {
e.preventDefault();
setActive(activeIndex <= 0 ? count - 1 : activeIndex - 1);
} else if (e.key === "Enter" && activeIndex >= 0 && !list.hidden) {
e.preventDefault();
choose(activeIndex);
} else if (e.key === "Escape") {
close();
}
});
input.addEventListener("blur", close);
return { show, close };
}The details that make it behave:
- mousedown instead of click. A click fires after the input loses focus, so a blur handler would close the list before the click lands. Handling mousedown and calling preventDefault() keeps focus in the input.
- Every new result list resets the highlight. Otherwise the highlighted index can point at an option that no longer exists.
- Enter only acts when an option is highlighted, and then calls preventDefault() so it doesn't also submit a surrounding form.
- Arrow keys wrap from the last option to the first and back, and scrollIntoView({ block: "nearest" }) keeps the highlighted option visible.
6. Loading Dynamic Data from an API
For a few thousand options or fewer, load them once and filter in the browser. Filtering 100,000 strings took about 5 ms in our test, so there is no need to debounce local filtering.
const input = document.getElementById("user-input");
const list = document.getElementById("user-list");
let users = [];
const dropdown = createDropdown(input, list, {
onSelect: (name) => {
document.getElementById("selected").textContent = `Selected: ${name}`;
},
});
function update() {
dropdown.show(filterItems(users, input.value), input.value);
}
input.addEventListener("input", update);
input.addEventListener("focus", update);
async function loadUsers() {
try {
const res = await fetch("https://dummyjson.com/users?limit=0&select=firstName,lastName");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
users = data.users.map((u) => `${u.firstName} ${u.lastName}`);
if (document.activeElement === input) update();
} catch (err) {
dropdown.show([], "", "Couldn't load users");
console.error(err);
}
}
loadUsers();- If the user focuses the input before the data arrives, update() runs again when it loads, so the list fills in instead of staying empty.
- A failed request shows a message in the list instead of failing silently.
7. Searching on the Server as the User Types
When the full list is too big to download, send the query to the server. Now debouncing matters, and so does cancelling requests that are no longer needed:
const input = document.getElementById("user-input");
const list = document.getElementById("user-list");
const dropdown = createDropdown(input, list);
let timer;
let controller;
input.addEventListener("input", () => {
clearTimeout(timer);
const query = input.value.trim();
if (query.length < 2) return dropdown.close();
timer = setTimeout(async () => {
controller?.abort(); // cancel the previous request
controller = new AbortController();
try {
const url = `https://dummyjson.com/users/search?q=${encodeURIComponent(query)}&limit=10&select=firstName,lastName`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const names = data.users.map((u) => `${u.firstName} ${u.lastName}`);
dropdown.show(names, query);
} catch (err) {
if (err.name !== "AbortError") dropdown.show([], query, "Search failed, try again");
}
}, 300);
});- Debounce (300 ms): typing "john" quickly sends one request, not four.
- AbortController: if a new search starts while the previous request is still in flight, the old one is cancelled. Without this, a slow response for "jo" can arrive after the response for "john" and overwrite it.
- Minimum length: nothing is sent for a single character.
- Errors: a non-OK response shows "Search failed, try again"; an aborted request is ignored.
8. Performance with Large Datasets
Measured in Chrome 152 with 100,000 items — filter plus render, median of several runs:
| Query | Render every match | Render first 50 matches |
|---|---|---|
| "a" (100,000 matches) | ~1,600 ms | ~1.5 ms |
| "99999" (1 match) | ~6 ms | ~6 ms |
Filtering itself is cheap. Creating DOM nodes is what costs time, so cap the results. If users genuinely need to browse thousands of options, use list virtualisation, but for a search box a cap is enough. Your numbers will differ by device, but the gap won't.
Complete Code
Three files. Serve the folder with a local server (npx serve or VS Code Live Server) rather than opening the file directly.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Searchable Dropdown</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<label for="user-input">User</label>
<div class="combo">
<input id="user-input" type="text" role="combobox" autocomplete="off"
aria-autocomplete="list" aria-expanded="false" aria-controls="user-list"
placeholder="Type a name...">
<ul id="user-list" role="listbox" aria-label="Users" hidden></ul>
</div>
<p id="selected"></p>
<script src="dropdown.js"></script>
<script>
const input = document.getElementById("user-input");
const list = document.getElementById("user-list");
let users = [];
const dropdown = createDropdown(input, list, {
onSelect: (name) => {
document.getElementById("selected").textContent = `Selected: ${name}`;
},
});
function update() {
dropdown.show(filterItems(users, input.value), input.value);
}
input.addEventListener("input", update);
input.addEventListener("focus", update);
async function loadUsers() {
try {
const res = await fetch("https://dummyjson.com/users?limit=0&select=firstName,lastName");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
users = data.users.map((u) => `${u.firstName} ${u.lastName}`);
if (document.activeElement === input) update();
} catch (err) {
dropdown.show([], "", "Couldn't load users");
console.error(err);
}
}
loadUsers();
</script>
</body>
</html>styles.css
.combo {
position: relative;
width: 300px;
}
.combo input {
width: 100%;
box-sizing: border-box;
padding: 8px;
border: 1px solid #ccc;
border-radius: 6px;
}
.combo [role="listbox"] {
position: absolute;
z-index: 10;
width: 100%;
max-height: 240px;
overflow-y: auto;
margin: 4px 0 0;
padding: 0;
list-style: none;
background: #fff;
border: 1px solid #ccc;
border-radius: 6px;
}
.combo [role="option"] {
padding: 8px;
cursor: pointer;
}
.combo [role="option"]:hover,
.combo [role="option"][aria-selected="true"] {
background: #e8f0fe;
}
.combo .empty {
color: #666;
cursor: default;
}
.combo mark {
background: none;
font-weight: 600;
color: inherit;
}dropdown.js
function filterItems(items, query, maxResults = 50) {
const q = query.trim().toLowerCase();
const matches = [];
for (const item of items) {
if (item.toLowerCase().includes(q)) {
matches.push(item);
if (matches.length === maxResults) break;
}
}
return matches;
}
function appendHighlighted(el, text, query) {
const q = query.trim().toLowerCase();
const start = q ? text.toLowerCase().indexOf(q) : -1;
if (start === -1) {
el.textContent = text;
return;
}
const mark = document.createElement("mark");
mark.textContent = text.slice(start, start + q.length);
el.append(text.slice(0, start), mark, text.slice(start + q.length));
}
function createDropdown(input, list, { onSelect } = {}) {
let options = [];
let activeIndex = -1;
function open() {
list.hidden = false;
input.setAttribute("aria-expanded", "true");
}
function close() {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
input.removeAttribute("aria-activedescendant");
activeIndex = -1;
}
function setActive(index) {
const items = list.querySelectorAll('[role="option"]:not([aria-disabled])');
items.forEach((li) => li.setAttribute("aria-selected", "false"));
activeIndex = index;
const li = items[index];
li.setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", li.id);
li.scrollIntoView({ block: "nearest" });
}
function choose(index) {
input.value = options[index];
close();
onSelect?.(options[index]);
}
function show(items, query, message = "No results") {
options = items;
activeIndex = -1;
input.removeAttribute("aria-activedescendant");
list.replaceChildren();
if (items.length === 0) {
const li = document.createElement("li");
li.setAttribute("role", "option");
li.setAttribute("aria-disabled", "true");
li.className = "empty";
li.textContent = message;
list.append(li);
}
items.forEach((item, i) => {
const li = document.createElement("li");
li.id = `${list.id}-option-${i}`;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", "false");
appendHighlighted(li, item, query);
// mousedown + preventDefault keeps focus in the input, so blur
// doesn't close the list before the click lands
li.addEventListener("mousedown", (e) => {
e.preventDefault();
choose(i);
});
list.append(li);
});
open();
}
input.addEventListener("keydown", (e) => {
const count = options.length;
if (e.key === "ArrowDown" && count > 0 && !list.hidden) {
e.preventDefault();
setActive((activeIndex + 1) % count);
} else if (e.key === "ArrowUp" && count > 0 && !list.hidden) {
e.preventDefault();
setActive(activeIndex <= 0 ? count - 1 : activeIndex - 1);
} else if (e.key === "Enter" && activeIndex >= 0 && !list.hidden) {
e.preventDefault();
choose(activeIndex);
} else if (e.key === "Escape") {
close();
}
});
input.addEventListener("blur", close);
return { show, close };
}For server-side search, replace the inline script in index.html with the one from section 7.
Common Searchable Dropdown Bugs
| Bug | Cause | Fix |
|---|---|---|
| Filter runs twice per keystroke | Both a debounced and a plain input listener are registered | Register one listener |
| TypeError on Enter | Reading options[currentIndex] when nothing is highlighted | Only act on Enter when activeIndex >= 0 |
| Clicking an option does nothing | Blur closes the list before click fires | Select on mousedown and call preventDefault() |
| Wrong option selected after typing more | Highlight index not reset when the list changes | Reset it on every render |
| Old results replace new ones | Responses arrive out of order | Abort the previous request |
| Page freezes on short queries | Thousands of list items created | Render at most ~50 results |
| Script runs from an option's text | Matches highlighted with innerHTML | Build the option from text nodes |
FAQs
What is a searchable dropdown in JavaScript?
A text input combined with a list of options that is filtered as the user types, so they can find one option among hundreds without scrolling.
Can I make a searchable dropdown without JavaScript?
Yes, with <input list> and <datalist>. It needs no script, but you can't style the options, highlight matches, or control how matching works.
How do I load dropdown options from an API?
Fetch the data once, map it to strings, and filter it in the browser (section 6). If the dataset is too large to download, query the server on each search with a debounce and an AbortController (section 7).
Do I need debouncing?
For server requests, yes — it turns one request per keystroke into one request per pause. For filtering a local array it usually isn't needed; filtering 100,000 strings took about 5 ms in our test.
How do I add keyboard navigation?
Listen for keydown on the input: ArrowDown/ArrowUp move the highlight, Enter selects the highlighted option, Escape closes the list. Keep focus in the input and point aria-activedescendant at the highlighted option.
How do I make it accessible?
Use role="combobox" on the input with aria-expanded, aria-controls and aria-activedescendant, role="listbox" on the list, and role="option" with aria-selected on each item. Give the input a visible <label>.
How do I handle very large lists?
Cap how many matches you render (50 is plenty for a search box). Rendering every match for a common query on 100,000 items took about 1.6 seconds in our test; rendering 50 took about 1.5 ms.
Can I make it multi-select?
Yes: keep an array of selected values, don't close the list on selection, show the selections as removable chips next to the input, and set aria-multiselectable="true" on the listbox.
Related Articles
View allAbout Muhaymin Bin Mehmood
Front-end Developer skilled in the MERN stack, experienced in web and mobile development. Proficient in React.js, Node.js, and Express.js, with a focus on client interactions, sales support, and high-performance applications.




