UI Components Hard
ARIA Combobox
Accessible combobox autocomplete implementing ARIA 1.2 combobox pattern with keyboard navigation and screen reader support.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e4e4e7;
line-height: 1.6;
min-height: 100vh;
}
.demo {
max-width: 640px;
margin: 0 auto;
padding: 3rem 1.5rem;
}
.demo-title {
font-size: 1.75rem;
font-weight: 700;
color: #fafafa;
letter-spacing: -0.02em;
}
.demo-sub {
color: #a1a1aa;
margin-top: 0.25rem;
font-size: 0.95rem;
}
/* Combobox */
.combobox-section {
margin-top: 2rem;
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.combobox-container {
position: relative;
}
.combobox-label {
display: block;
font-size: 0.85rem;
font-weight: 500;
color: #a1a1aa;
margin-bottom: 0.5rem;
}
.combobox-wrapper {
position: relative;
}
.combobox-input-wrap {
position: relative;
display: flex;
align-items: center;
}
.combobox-search-icon {
position: absolute;
left: 0.85rem;
color: #52525b;
pointer-events: none;
}
.combobox-input {
width: 100%;
padding: 0.7rem 2.5rem 0.7rem 2.5rem;
background: #09090b;
border: 1px solid #27272a;
border-radius: 10px;
color: #e4e4e7;
font-size: 0.9rem;
font-family: inherit;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.combobox-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.combobox-input::placeholder {
color: #52525b;
}
.combobox-clear {
position: absolute;
right: 0.6rem;
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: transparent;
border: none;
color: #52525b;
cursor: pointer;
border-radius: 6px;
transition: all 0.15s;
}
.combobox-clear:hover {
background: #1e1e22;
color: #a1a1aa;
}
.combobox-clear:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.combobox-clear[hidden] {
display: none;
}
/* Listbox */
.combobox-listbox {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: #111113;
border: 1px solid #27272a;
border-radius: 10px;
list-style: none;
max-height: 280px;
overflow-y: auto;
z-index: 100;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
padding: 4px;
animation: dropIn 0.15s ease-out;
}
.combobox-listbox[hidden] {
display: none;
}
.combobox-option {
padding: 0.55rem 0.85rem;
font-size: 0.875rem;
color: #d4d4d8;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.6rem;
transition: background 0.1s;
}
.combobox-option:hover {
background: #1e1e22;
}
.combobox-option--active {
background: rgba(59, 130, 246, 0.12);
color: #60a5fa;
}
.combobox-option[aria-selected="true"] {
color: #3b82f6;
}
.combobox-option[aria-selected="true"]::after {
content: "";
margin-left: auto;
width: 16px;
height: 16px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
}
.combobox-option-flag {
font-size: 1.1rem;
line-height: 1;
}
.combobox-no-results {
padding: 1rem;
font-size: 0.85rem;
color: #52525b;
text-align: center;
}
/* Status */
.combobox-status {
font-size: 0.78rem;
color: #52525b;
margin-top: 0.35rem;
min-height: 1.25rem;
}
/* Selected display */
.selected-display {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid #1e1e22;
display: flex;
align-items: center;
gap: 0.5rem;
}
.selected-label {
font-size: 0.8rem;
color: #71717a;
}
.selected-value {
font-size: 0.9rem;
font-weight: 500;
color: #fafafa;
}
/* Info Panel */
.info-panel {
margin-top: 2rem;
background: #111113;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.5rem;
}
.info-title {
font-size: 1rem;
font-weight: 600;
color: #fafafa;
margin-bottom: 0.75rem;
}
.info-subtitle {
font-size: 0.9rem;
font-weight: 600;
color: #fafafa;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
}
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.15rem;
font-size: 0.8rem;
color: #71717a;
}
.info-item code {
background: #1e1e22;
padding: 0.12rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
color: #60a5fa;
font-family: "SF Mono", "Fira Code", monospace;
width: fit-content;
}
.kbd-grid {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.kbd-item {
font-size: 0.8rem;
color: #a1a1aa;
}
kbd {
display: inline-block;
padding: 0.08rem 0.4rem;
background: #1e1e22;
border: 1px solid #3f3f46;
border-radius: 4px;
font-size: 0.73rem;
font-family: "SF Mono", "Fira Code", monospace;
color: #d4d4d8;
margin-right: 0.25rem;
}
/* Scrollbar */
.combobox-listbox::-webkit-scrollbar {
width: 4px;
}
.combobox-listbox::-webkit-scrollbar-track {
background: transparent;
}
.combobox-listbox::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 4px;
}
@keyframes dropIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}(() => {
const countries = [
{ name: "Argentina", flag: "\uD83C\uDDE6\uD83C\uDDF7" },
{ name: "Australia", flag: "\uD83C\uDDE6\uD83C\uDDFA" },
{ name: "Brazil", flag: "\uD83C\uDDE7\uD83C\uDDF7" },
{ name: "Canada", flag: "\uD83C\uDDE8\uD83C\uDDE6" },
{ name: "China", flag: "\uD83C\uDDE8\uD83C\uDDF3" },
{ name: "Denmark", flag: "\uD83C\uDDE9\uD83C\uDDF0" },
{ name: "Egypt", flag: "\uD83C\uDDEA\uD83C\uDDEC" },
{ name: "France", flag: "\uD83C\uDDEB\uD83C\uDDF7" },
{ name: "Germany", flag: "\uD83C\uDDE9\uD83C\uDDEA" },
{ name: "India", flag: "\uD83C\uDDEE\uD83C\uDDF3" },
{ name: "Italy", flag: "\uD83C\uDDEE\uD83C\uDDF9" },
{ name: "Japan", flag: "\uD83C\uDDEF\uD83C\uDDF5" },
{ name: "Kenya", flag: "\uD83C\uDDF0\uD83C\uDDEA" },
{ name: "Mexico", flag: "\uD83C\uDDF2\uD83C\uDDFD" },
{ name: "Netherlands", flag: "\uD83C\uDDF3\uD83C\uDDF1" },
{ name: "Norway", flag: "\uD83C\uDDF3\uD83C\uDDF4" },
{ name: "Portugal", flag: "\uD83C\uDDF5\uD83C\uDDF9" },
{ name: "South Korea", flag: "\uD83C\uDDF0\uD83C\uDDF7" },
{ name: "Spain", flag: "\uD83C\uDDEA\uD83C\uDDF8" },
{ name: "Sweden", flag: "\uD83C\uDDF8\uD83C\uDDEA" },
{ name: "United Kingdom", flag: "\uD83C\uDDEC\uD83C\uDDE7" },
{ name: "United States", flag: "\uD83C\uDDFA\uD83C\uDDF8" },
];
const input = document.getElementById("country-input");
const listbox = document.getElementById("country-listbox");
const combobox = document.querySelector('[role="combobox"]');
const clearBtn = document.getElementById("clear-input");
const statusEl = document.getElementById("combobox-status");
const selectedValue = document.getElementById("selected-value");
let activeIndex = -1;
let filteredCountries = [...countries];
let isOpen = false;
function renderOptions(filter = "") {
const query = filter.toLowerCase().trim();
filteredCountries = query
? countries.filter((c) => c.name.toLowerCase().includes(query))
: [...countries];
listbox.innerHTML = "";
activeIndex = -1;
if (filteredCountries.length === 0) {
listbox.innerHTML = '<li class="combobox-no-results">No countries found</li>';
updateStatus("No results");
return;
}
filteredCountries.forEach((country, i) => {
const li = document.createElement("li");
li.id = `option-${i}`;
li.className = "combobox-option";
li.setAttribute("role", "option");
li.setAttribute("aria-selected", "false");
li.innerHTML = `<span class="combobox-option-flag" aria-hidden="true">${country.flag}</span>${country.name}`;
li.addEventListener("click", () => selectOption(i));
li.addEventListener("mouseenter", () => {
setActiveOption(i, false);
});
listbox.appendChild(li);
});
const count = filteredCountries.length;
updateStatus(
count === 1
? "1 result available"
: `${count} results available. Use Up and Down arrows to navigate.`
);
}
function updateStatus(text) {
statusEl.textContent = text;
}
function openListbox() {
if (isOpen) return;
isOpen = true;
listbox.hidden = false;
combobox.setAttribute("aria-expanded", "true");
renderOptions(input.value);
}
function closeListbox() {
if (!isOpen) return;
isOpen = false;
listbox.hidden = true;
combobox.setAttribute("aria-expanded", "false");
input.setAttribute("aria-activedescendant", "");
activeIndex = -1;
updateStatus("");
}
function setActiveOption(index, scroll = true) {
// Remove active from previous
const options = listbox.querySelectorAll('[role="option"]');
options.forEach((opt) => opt.classList.remove("combobox-option--active"));
if (index < 0 || index >= filteredCountries.length) {
activeIndex = -1;
input.setAttribute("aria-activedescendant", "");
return;
}
activeIndex = index;
const activeEl = options[index];
activeEl.classList.add("combobox-option--active");
input.setAttribute("aria-activedescendant", activeEl.id);
if (scroll) {
activeEl.scrollIntoView({ block: "nearest" });
}
}
function selectOption(index) {
const country = filteredCountries[index];
if (!country) return;
input.value = country.name;
selectedValue.textContent = `${country.flag} ${country.name}`;
clearBtn.hidden = false;
// Mark as selected
const options = listbox.querySelectorAll('[role="option"]');
options.forEach((opt) => opt.setAttribute("aria-selected", "false"));
if (options[index]) {
options[index].setAttribute("aria-selected", "true");
}
closeListbox();
updateStatus(`${country.name} selected`);
}
// Input events
input.addEventListener("input", () => {
clearBtn.hidden = !input.value;
if (!isOpen) openListbox();
renderOptions(input.value);
});
input.addEventListener("focus", () => {
openListbox();
});
input.addEventListener("keydown", (e) => {
const optionCount = filteredCountries.length;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
if (!isOpen) {
openListbox();
} else {
setActiveOption(activeIndex < optionCount - 1 ? activeIndex + 1 : 0);
}
break;
case "ArrowUp":
e.preventDefault();
if (!isOpen) {
openListbox();
} else {
setActiveOption(activeIndex > 0 ? activeIndex - 1 : optionCount - 1);
}
break;
case "Home":
if (isOpen && optionCount > 0) {
e.preventDefault();
setActiveOption(0);
}
break;
case "End":
if (isOpen && optionCount > 0) {
e.preventDefault();
setActiveOption(optionCount - 1);
}
break;
case "Enter":
e.preventDefault();
if (isOpen && activeIndex >= 0) {
selectOption(activeIndex);
}
break;
case "Escape":
if (isOpen) {
e.preventDefault();
closeListbox();
}
break;
}
});
// Clear button
clearBtn.addEventListener("click", () => {
input.value = "";
clearBtn.hidden = true;
selectedValue.textContent = "None";
input.focus();
renderOptions("");
});
// Close on outside click
document.addEventListener("click", (e) => {
if (!e.target.closest(".combobox-wrapper")) {
closeListbox();
}
});
// Initialize
renderOptions("");
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ARIA Combobox</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">ARIA Combobox</h1>
<p class="demo-sub">Accessible autocomplete implementing ARIA 1.2 combobox pattern with keyboard navigation.</p>
<div class="combobox-section">
<div class="combobox-container">
<label class="combobox-label" id="country-label" for="country-input">Select a country</label>
<div class="combobox-wrapper">
<div class="combobox-input-wrap"
role="combobox"
aria-expanded="false"
aria-haspopup="listbox"
aria-owns="country-listbox">
<svg class="combobox-search-icon" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text"
id="country-input"
class="combobox-input"
aria-autocomplete="list"
aria-controls="country-listbox"
aria-activedescendant=""
aria-labelledby="country-label"
placeholder="Type to search countries..."
autocomplete="off" />
<button class="combobox-clear" id="clear-input" aria-label="Clear selection" hidden>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<ul id="country-listbox"
class="combobox-listbox"
role="listbox"
aria-labelledby="country-label"
hidden>
<!-- Options populated by JS -->
</ul>
</div>
<div class="combobox-status" role="status" aria-live="polite" id="combobox-status"></div>
</div>
<div class="selected-display">
<span class="selected-label">Selected:</span>
<span class="selected-value" id="selected-value">None</span>
</div>
</div>
<!-- Info Panel -->
<div class="info-panel">
<h3 class="info-title">ARIA Attributes</h3>
<div class="info-grid">
<div class="info-item">
<code>role="combobox"</code>
<span>Container wrapping the input</span>
</div>
<div class="info-item">
<code>role="listbox"</code>
<span>The dropdown options list</span>
</div>
<div class="info-item">
<code>role="option"</code>
<span>Each selectable item</span>
</div>
<div class="info-item">
<code>aria-expanded</code>
<span>Whether the listbox is open</span>
</div>
<div class="info-item">
<code>aria-activedescendant</code>
<span>ID of the focused option</span>
</div>
<div class="info-item">
<code>aria-autocomplete="list"</code>
<span>Filtering suggestions are shown</span>
</div>
</div>
<h4 class="info-subtitle">Keyboard Controls</h4>
<div class="kbd-grid">
<span class="kbd-item"><kbd>Down</kbd> Open listbox / next option</span>
<span class="kbd-item"><kbd>Up</kbd> Previous option</span>
<span class="kbd-item"><kbd>Enter</kbd> Select highlighted option</span>
<span class="kbd-item"><kbd>Escape</kbd> Close listbox</span>
<span class="kbd-item"><kbd>Home</kbd> First option</span>
<span class="kbd-item"><kbd>End</kbd> Last option</span>
</div>
</div>
</div>
<script src="script.js"></script>
</body>
</html>An autocomplete combobox implementing the ARIA 1.2 combobox pattern with type-to-filter, keyboard navigation (Up/Down/Enter/Escape), and aria-activedescendant management. Result counts are announced via a live region for screen reader users.