UI Components Medium
Autocomplete
Text input with a filtered dropdown suggestion list. Keyboard navigation (↑↓ Enter Escape), highlight match, clear button.
Open in Lab
MCP
css vanilla-js
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
background: #050910;
color: #f2f6ff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo {
width: 100%;
max-width: 420px;
}
.demo-title {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.375rem;
}
.demo-sub {
color: #475569;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.section {
margin-bottom: 1.5rem;
}
.field-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: #cbd5e1;
margin-bottom: 0.5rem;
}
/* ── Wrapper ── */
.ac-wrap {
position: relative;
}
/* ── Input row ── */
.ac-input-row {
position: relative;
display: flex;
align-items: center;
}
.ac-input {
width: 100%;
height: 2.75rem;
padding: 0 2.5rem 0 0.875rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: #f2f6ff;
font-family: inherit;
font-size: 0.9375rem;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.ac-input::placeholder {
color: #334155;
}
.ac-input:focus {
border-color: rgba(99, 179, 237, 0.5);
box-shadow: 0 0 0 3px rgba(99, 179, 237, 0.12);
}
/* ── Clear button ── */
.ac-clear {
position: absolute;
right: 0.625rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.625rem;
height: 1.625rem;
border: none;
background: rgba(255, 255, 255, 0.07);
color: #475569;
border-radius: 50%;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.ac-clear:hover {
background: rgba(255, 255, 255, 0.12);
color: #94a3b8;
}
.ac-clear svg {
width: 0.75rem;
height: 0.75rem;
}
/* ── Dropdown listbox ── */
.ac-listbox {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
list-style: none;
background: #0d1525;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.5);
z-index: 100;
max-height: 16rem;
overflow-y: auto;
}
.ac-listbox::-webkit-scrollbar {
width: 4px;
}
.ac-listbox::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
/* ── Option items ── */
.ac-option {
padding: 0.625rem 0.875rem;
font-size: 0.9rem;
color: #94a3b8;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.ac-option:hover,
.ac-option[aria-selected="true"] {
background: rgba(99, 179, 237, 0.12);
color: #f2f6ff;
}
.ac-option mark {
background: transparent;
color: #38bdf8;
font-weight: 700;
}
.ac-no-results {
padding: 0.875rem;
font-size: 0.875rem;
color: #475569;
text-align: center;
}var ITEMS = [
"Apple",
"Apricot",
"Avocado",
"Banana",
"Blueberry",
"Broccoli",
"Carrot",
"Cherry",
"Cucumber",
"Grape",
"Kiwi",
"Lemon",
"Mango",
"Orange",
"Papaya",
"Peach",
"Pear",
"Pineapple",
"Raspberry",
"Strawberry",
"Tomato",
"Watermelon",
];
var input = document.getElementById("ac-input");
var listbox = document.getElementById("ac-listbox");
var clearBtn = document.querySelector(".ac-clear");
var activeIdx = -1;
function escape(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function highlight(text, query) {
if (!query) return text;
var re = new RegExp("(" + escape(query) + ")", "gi");
return text.replace(re, "<mark>$1</mark>");
}
function open(items, query) {
listbox.innerHTML = "";
activeIdx = -1;
if (items.length === 0) {
var empty = document.createElement("li");
empty.className = "ac-no-results";
empty.textContent = "No results found";
listbox.appendChild(empty);
} else {
items.forEach(function (item, i) {
var li = document.createElement("li");
li.className = "ac-option";
li.id = "ac-opt-" + i;
li.setAttribute("role", "option");
li.setAttribute("aria-selected", "false");
li.innerHTML = highlight(item, query);
li.dataset.value = item;
li.addEventListener("mousedown", function (e) {
e.preventDefault();
select(item);
});
listbox.appendChild(li);
});
}
listbox.hidden = false;
input.setAttribute("aria-expanded", "true");
}
function close() {
listbox.hidden = true;
input.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
activeIdx = -1;
}
function select(value) {
input.value = value;
clearBtn.hidden = false;
close();
}
function setActive(idx) {
var options = listbox.querySelectorAll(".ac-option");
options.forEach(function (o) {
o.setAttribute("aria-selected", "false");
});
if (idx < 0 || idx >= options.length) {
activeIdx = -1;
input.setAttribute("aria-activedescendant", "");
return;
}
activeIdx = idx;
var active = options[idx];
active.setAttribute("aria-selected", "true");
input.setAttribute("aria-activedescendant", active.id);
active.scrollIntoView({ block: "nearest" });
}
input.addEventListener("input", function () {
var q = input.value.trim();
clearBtn.hidden = q.length === 0;
if (!q) {
close();
return;
}
var filtered = ITEMS.filter(function (item) {
return item.toLowerCase().includes(q.toLowerCase());
});
open(filtered, q);
});
input.addEventListener("keydown", function (e) {
var options = listbox.querySelectorAll(".ac-option");
if (listbox.hidden) {
if (e.key === "ArrowDown") {
e.preventDefault();
input.dispatchEvent(new Event("input"));
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive(Math.min(activeIdx + 1, options.length - 1));
break;
case "ArrowUp":
e.preventDefault();
setActive(Math.max(activeIdx - 1, 0));
break;
case "Enter":
e.preventDefault();
if (activeIdx >= 0 && options[activeIdx]) {
select(options[activeIdx].dataset.value);
}
break;
case "Escape":
input.value = "";
clearBtn.hidden = true;
close();
break;
case "Tab":
close();
break;
}
});
input.addEventListener("blur", function () {
// small delay so mousedown on option fires first
setTimeout(close, 150);
});
clearBtn.addEventListener("click", function () {
input.value = "";
clearBtn.hidden = true;
input.focus();
close();
});<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Autocomplete</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">Autocomplete</h1>
<p class="demo-sub">Filter suggestions as you type. Navigate with ↑↓ and confirm with Enter.</p>
<section class="section">
<label class="field-label" for="ac-input">Fruit or vegetable</label>
<div class="ac-wrap">
<div class="ac-input-row">
<input
id="ac-input"
class="ac-input"
type="text"
role="combobox"
autocomplete="off"
spellcheck="false"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="ac-listbox"
aria-activedescendant=""
placeholder="Type to search…"
/>
<button class="ac-clear" aria-label="Clear" hidden>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<ul
id="ac-listbox"
class="ac-listbox"
role="listbox"
aria-label="Suggestions"
hidden
></ul>
</div>
</section>
</div>
<script src="script.js"></script>
</body>
</html>Autocomplete
ARIA combobox with a filtered suggestion list. Matches are highlighted inline. Keyboard-navigable and screen-reader friendly.
Keyboard shortcuts
| Key | Action |
|---|---|
| ↓ / ↑ | Move active item in list |
| Enter | Confirm active item |
| Escape | Close list and clear |
| Tab | Close list |
Implementation
Uses role="combobox" on the input with aria-expanded, aria-controls, and aria-activedescendant. Suggestions are a role="listbox" with role="option" items. Matched substrings are wrapped in <mark> for visual highlighting.