UI Components Medium
Combobox
Searchable dropdown select with real-time filtering, keyboard navigation (arrow keys, enter, escape), and accessible ARIA attributes.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Inter, system-ui, sans-serif;
background: #0a0a0a;
color: #f2f6ff;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.demo {
width: 100%;
max-width: 640px;
}
.demo-title {
font-size: 1.5rem;
font-weight: 800;
margin-bottom: 0.375rem;
}
.demo-sub {
color: #475569;
font-size: 0.875rem;
margin-bottom: 2rem;
}
.demo-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
margin-bottom: 1.5rem;
}
@media (max-width: 520px) {
.demo-row {
grid-template-columns: 1fr;
}
}
/* ── Combobox ── */
.combobox {
position: relative;
}
.combobox-label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.combobox-input-wrap {
position: relative;
}
.combobox-input {
width: 100%;
padding: 0.625rem 2.25rem 0.625rem 0.875rem;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 0.625rem;
color: #f2f6ff;
font-size: 0.875rem;
font-family: inherit;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.combobox-input::placeholder {
color: #4a4a4a;
}
.combobox-input:focus {
border-color: #38bdf8;
box-shadow: 0 0 0 3px rgba(56, 189, 248, 0.15);
}
.combobox-chevron {
position: absolute;
right: 0.625rem;
top: 50%;
transform: translateY(-50%);
color: #4a4a4a;
pointer-events: none;
transition: transform 0.2s;
}
.combobox.is-open .combobox-chevron {
transform: translateY(-50%) rotate(180deg);
}
/* ── Dropdown ── */
.combobox-list {
position: absolute;
top: calc(100% + 0.375rem);
left: 0;
right: 0;
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 0.625rem;
max-height: 14rem;
overflow-y: auto;
list-style: none;
z-index: 50;
opacity: 0;
visibility: hidden;
transform: translateY(-4px);
transition: opacity 0.15s, transform 0.15s, visibility 0.15s;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
scrollbar-width: thin;
scrollbar-color: #2a2a2a transparent;
}
.combobox.is-open .combobox-list {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.combobox-option {
padding: 0.5rem 0.875rem;
font-size: 0.875rem;
color: #94a3b8;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.combobox-option:first-child {
border-radius: 0.5rem 0.5rem 0 0;
}
.combobox-option:last-child {
border-radius: 0 0 0.5rem 0.5rem;
}
.combobox-option:hover {
background: rgba(56, 189, 248, 0.06);
color: #f2f6ff;
}
.combobox-option.is-active {
background: rgba(56, 189, 248, 0.12);
color: #38bdf8;
}
.combobox-option.is-selected {
color: #38bdf8;
font-weight: 600;
}
.combobox-option.is-hidden {
display: none;
}
.combobox-empty {
padding: 0.75rem 0.875rem;
font-size: 0.8125rem;
color: #4a4a4a;
text-align: center;
}(function () {
document.querySelectorAll("[data-combobox]").forEach(initCombobox);
function initCombobox(root) {
var input = root.querySelector(".combobox-input");
var list = root.querySelector(".combobox-list");
var options = Array.from(root.querySelectorAll(".combobox-option"));
var activeIndex = -1;
var selectedValue = null;
function open() {
root.classList.add("is-open");
input.setAttribute("aria-expanded", "true");
}
function close() {
root.classList.remove("is-open");
input.setAttribute("aria-expanded", "false");
activeIndex = -1;
clearActive();
}
function clearActive() {
options.forEach(function (o) {
o.classList.remove("is-active");
});
}
function setActive(idx) {
clearActive();
var visible = getVisible();
if (idx < 0 || idx >= visible.length) return;
activeIndex = idx;
visible[idx].classList.add("is-active");
visible[idx].scrollIntoView({ block: "nearest" });
}
function getVisible() {
return options.filter(function (o) {
return !o.classList.contains("is-hidden");
});
}
function selectOption(opt) {
selectedValue = opt.getAttribute("data-value");
input.value = opt.textContent;
options.forEach(function (o) {
o.classList.remove("is-selected");
});
opt.classList.add("is-selected");
close();
}
function filter() {
var query = input.value.toLowerCase().trim();
var anyVisible = false;
options.forEach(function (opt) {
var text = opt.textContent.toLowerCase();
if (text.indexOf(query) !== -1) {
opt.classList.remove("is-hidden");
anyVisible = true;
} else {
opt.classList.add("is-hidden");
}
});
/* Remove or add empty message */
var existing = list.querySelector(".combobox-empty");
if (!anyVisible) {
if (!existing) {
var msg = document.createElement("li");
msg.className = "combobox-empty";
msg.textContent = "No results found";
list.appendChild(msg);
}
} else if (existing) {
existing.remove();
}
activeIndex = -1;
clearActive();
}
input.addEventListener("focus", function () {
open();
filter();
});
input.addEventListener("input", function () {
open();
filter();
});
input.addEventListener("keydown", function (e) {
var visible = getVisible();
if (e.key === "ArrowDown") {
e.preventDefault();
if (!root.classList.contains("is-open")) {
open();
filter();
}
var next = activeIndex + 1;
if (next >= visible.length) next = 0;
setActive(next);
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!root.classList.contains("is-open")) {
open();
filter();
}
var prev = activeIndex - 1;
if (prev < 0) prev = visible.length - 1;
setActive(prev);
} else if (e.key === "Enter") {
e.preventDefault();
var vis = getVisible();
if (activeIndex >= 0 && activeIndex < vis.length) {
selectOption(vis[activeIndex]);
}
} else if (e.key === "Escape") {
close();
input.blur();
}
});
options.forEach(function (opt) {
opt.addEventListener("click", function () {
selectOption(opt);
});
opt.addEventListener("mouseenter", function () {
var vis = getVisible();
var idx = vis.indexOf(opt);
if (idx !== -1) setActive(idx);
});
});
document.addEventListener("click", function (e) {
if (!root.contains(e.target)) close();
});
}
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Combobox</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="demo">
<h1 class="demo-title">Combobox</h1>
<p class="demo-sub">Searchable dropdown select with keyboard navigation.</p>
<div class="demo-row">
<!-- Example 1: Frameworks -->
<div class="combobox" data-combobox>
<label class="combobox-label">Framework</label>
<div class="combobox-input-wrap">
<input
type="text"
class="combobox-input"
placeholder="Search frameworks..."
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
/>
<span class="combobox-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
<ul class="combobox-list" role="listbox">
<li class="combobox-option" role="option" data-value="react">React</li>
<li class="combobox-option" role="option" data-value="vue">Vue</li>
<li class="combobox-option" role="option" data-value="angular">Angular</li>
<li class="combobox-option" role="option" data-value="svelte">Svelte</li>
<li class="combobox-option" role="option" data-value="solid">SolidJS</li>
<li class="combobox-option" role="option" data-value="astro">Astro</li>
<li class="combobox-option" role="option" data-value="next">Next.js</li>
<li class="combobox-option" role="option" data-value="nuxt">Nuxt</li>
<li class="combobox-option" role="option" data-value="remix">Remix</li>
<li class="combobox-option" role="option" data-value="qwik">Qwik</li>
</ul>
</div>
<!-- Example 2: Countries -->
<div class="combobox" data-combobox>
<label class="combobox-label">Country</label>
<div class="combobox-input-wrap">
<input
type="text"
class="combobox-input"
placeholder="Search countries..."
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
/>
<span class="combobox-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
<ul class="combobox-list" role="listbox">
<li class="combobox-option" role="option" data-value="us">United States</li>
<li class="combobox-option" role="option" data-value="ca">Canada</li>
<li class="combobox-option" role="option" data-value="uk">United Kingdom</li>
<li class="combobox-option" role="option" data-value="de">Germany</li>
<li class="combobox-option" role="option" data-value="fr">France</li>
<li class="combobox-option" role="option" data-value="jp">Japan</li>
<li class="combobox-option" role="option" data-value="au">Australia</li>
<li class="combobox-option" role="option" data-value="br">Brazil</li>
<li class="combobox-option" role="option" data-value="in">India</li>
<li class="combobox-option" role="option" data-value="mx">Mexico</li>
</ul>
</div>
</div>
<!-- Example 3: With pre-selected value -->
<div class="combobox" data-combobox>
<label class="combobox-label">Color Theme</label>
<div class="combobox-input-wrap">
<input
type="text"
class="combobox-input"
placeholder="Pick a color..."
role="combobox"
aria-expanded="false"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
/>
<span class="combobox-chevron" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</span>
</div>
<ul class="combobox-list" role="listbox">
<li class="combobox-option" role="option" data-value="slate">Slate</li>
<li class="combobox-option" role="option" data-value="zinc">Zinc</li>
<li class="combobox-option" role="option" data-value="rose">Rose</li>
<li class="combobox-option" role="option" data-value="sky">Sky</li>
<li class="combobox-option" role="option" data-value="emerald">Emerald</li>
<li class="combobox-option" role="option" data-value="amber">Amber</li>
<li class="combobox-option" role="option" data-value="violet">Violet</li>
<li class="combobox-option" role="option" data-value="indigo">Indigo</li>
</ul>
</div>
</div>
<script src="script.js"></script>
</body>
</html>import { useState, useRef, useEffect, useCallback } from "react";
interface ComboboxOption {
value: string;
label: string;
}
interface ComboboxProps {
options: ComboboxOption[];
value?: string;
onChange?: (value: string, label: string) => void;
placeholder?: string;
label?: string;
}
export function Combobox({
options,
value,
onChange,
placeholder = "Search...",
label,
}: ComboboxProps) {
const [query, setQuery] = useState("");
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const [selectedValue, setSelectedValue] = useState(value ?? "");
const rootRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const filtered = options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()));
const open = () => {
setIsOpen(true);
setActiveIndex(-1);
};
const close = () => {
setIsOpen(false);
setActiveIndex(-1);
};
const select = (opt: ComboboxOption) => {
setSelectedValue(opt.value);
setQuery(opt.label);
onChange?.(opt.value, opt.label);
close();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen) open();
setActiveIndex((i) => (i + 1 >= filtered.length ? 0 : i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen) open();
setActiveIndex((i) => (i - 1 < 0 ? filtered.length - 1 : i - 1));
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex >= 0 && activeIndex < filtered.length) {
select(filtered[activeIndex]);
}
} else if (e.key === "Escape") {
close();
inputRef.current?.blur();
}
};
useEffect(() => {
if (activeIndex >= 0 && listRef.current) {
const item = listRef.current.children[activeIndex] as HTMLElement;
item?.scrollIntoView({ block: "nearest" });
}
}, [activeIndex]);
useEffect(() => {
function onClick(e: MouseEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
close();
}
}
document.addEventListener("click", onClick);
return () => document.removeEventListener("click", onClick);
}, []);
return (
<div ref={rootRef} style={{ position: "relative" }}>
{label && (
<label
style={{
display: "block",
fontSize: "0.75rem",
fontWeight: 600,
color: "#94a3b8",
textTransform: "uppercase",
letterSpacing: "0.05em",
marginBottom: "0.5rem",
}}
>
{label}
</label>
)}
<div style={{ position: "relative" }}>
<input
ref={inputRef}
type="text"
role="combobox"
aria-expanded={isOpen}
aria-autocomplete="list"
aria-haspopup="listbox"
autoComplete="off"
value={query}
placeholder={placeholder}
onFocus={open}
onChange={(e) => {
setQuery(e.target.value);
open();
}}
onKeyDown={handleKeyDown}
style={{
width: "100%",
padding: "0.625rem 2.25rem 0.625rem 0.875rem",
background: "#141414",
border: "1px solid #2a2a2a",
borderRadius: "0.625rem",
color: "#f2f6ff",
fontSize: "0.875rem",
fontFamily: "inherit",
outline: "none",
}}
/>
<span
style={{
position: "absolute",
right: "0.625rem",
top: "50%",
transform: `translateY(-50%) rotate(${isOpen ? 180 : 0}deg)`,
color: "#4a4a4a",
pointerEvents: "none",
transition: "transform 0.2s",
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path
d="M4 6l4 4 4-4"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
</div>
{isOpen && (
<ul
ref={listRef}
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 0.375rem)",
left: 0,
right: 0,
background: "#141414",
border: "1px solid #2a2a2a",
borderRadius: "0.625rem",
maxHeight: "14rem",
overflowY: "auto",
listStyle: "none",
zIndex: 50,
padding: 0,
margin: 0,
boxShadow: "0 8px 24px rgba(0,0,0,0.4)",
}}
>
{filtered.length === 0 ? (
<li
style={{
padding: "0.75rem 0.875rem",
fontSize: "0.8125rem",
color: "#4a4a4a",
textAlign: "center",
}}
>
No results found
</li>
) : (
filtered.map((opt, i) => (
<li
key={opt.value}
role="option"
aria-selected={selectedValue === opt.value}
onClick={() => select(opt)}
onMouseEnter={() => setActiveIndex(i)}
style={{
padding: "0.5rem 0.875rem",
fontSize: "0.875rem",
color:
i === activeIndex
? "#38bdf8"
: selectedValue === opt.value
? "#38bdf8"
: "#94a3b8",
background: i === activeIndex ? "rgba(56,189,248,0.12)" : "transparent",
cursor: "pointer",
fontWeight: selectedValue === opt.value ? 600 : 400,
transition: "background 0.1s, color 0.1s",
}}
>
{opt.label}
</li>
))
)}
</ul>
)}
</div>
);
}
/* Demo */
export default function ComboboxDemo() {
const [selected, setSelected] = useState("");
const frameworks = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "SolidJS" },
{ value: "astro", label: "Astro" },
{ value: "next", label: "Next.js" },
{ value: "nuxt", label: "Nuxt" },
{ value: "remix", label: "Remix" },
{ value: "qwik", label: "Qwik" },
];
const countries = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
{ value: "br", label: "Brazil" },
];
return (
<div
style={{
minHeight: "100vh",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: "#0a0a0a",
fontFamily: "Inter, system-ui, sans-serif",
color: "#f2f6ff",
padding: "2rem",
}}
>
<div style={{ width: "100%", maxWidth: 640 }}>
<h1 style={{ fontSize: "1.5rem", fontWeight: 800, marginBottom: "0.375rem" }}>Combobox</h1>
<p style={{ color: "#475569", fontSize: "0.875rem", marginBottom: "2rem" }}>
Searchable dropdown select with keyboard navigation.
</p>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: "1.5rem",
marginBottom: "1.5rem",
}}
>
<Combobox
label="Framework"
options={frameworks}
placeholder="Search frameworks..."
onChange={(v) => setSelected(v)}
/>
<Combobox label="Country" options={countries} placeholder="Search countries..." />
</div>
{selected && (
<p style={{ fontSize: "0.8125rem", color: "#94a3b8" }}>
Selected framework: <strong style={{ color: "#38bdf8" }}>{selected}</strong>
</p>
)}
</div>
</div>
);
}<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
const frameworks = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "SolidJS" },
{ value: "astro", label: "Astro" },
{ value: "next", label: "Next.js" },
{ value: "nuxt", label: "Nuxt" },
{ value: "remix", label: "Remix" },
{ value: "qwik", label: "Qwik" },
];
const countries = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
{ value: "br", label: "Brazil" },
];
const selectedFramework = ref("");
// Combobox 1
const query1 = ref("");
const isOpen1 = ref(false);
const activeIndex1 = ref(-1);
const selectedValue1 = ref("");
const rootEl1 = ref(null);
const inputEl1 = ref(null);
const listEl1 = ref(null);
// Combobox 2
const query2 = ref("");
const isOpen2 = ref(false);
const activeIndex2 = ref(-1);
const selectedValue2 = ref("");
const rootEl2 = ref(null);
const inputEl2 = ref(null);
const listEl2 = ref(null);
const filtered1 = computed(() =>
frameworks.filter((o) => o.label.toLowerCase().includes(query1.value.toLowerCase()))
);
const filtered2 = computed(() =>
countries.filter((o) => o.label.toLowerCase().includes(query2.value.toLowerCase()))
);
function select1(opt) {
selectedValue1.value = opt.value;
query1.value = opt.label;
selectedFramework.value = opt.value;
isOpen1.value = false;
activeIndex1.value = -1;
}
function select2(opt) {
selectedValue2.value = opt.value;
query2.value = opt.label;
isOpen2.value = false;
activeIndex2.value = -1;
}
function handleKey1(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen1.value) {
isOpen1.value = true;
activeIndex1.value = -1;
}
activeIndex1.value =
activeIndex1.value + 1 >= filtered1.value.length ? 0 : activeIndex1.value + 1;
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen1.value) {
isOpen1.value = true;
activeIndex1.value = -1;
}
activeIndex1.value =
activeIndex1.value - 1 < 0 ? filtered1.value.length - 1 : activeIndex1.value - 1;
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex1.value >= 0 && activeIndex1.value < filtered1.value.length)
select1(filtered1.value[activeIndex1.value]);
} else if (e.key === "Escape") {
isOpen1.value = false;
activeIndex1.value = -1;
inputEl1.value?.blur();
}
}
function handleKey2(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen2.value) {
isOpen2.value = true;
activeIndex2.value = -1;
}
activeIndex2.value =
activeIndex2.value + 1 >= filtered2.value.length ? 0 : activeIndex2.value + 1;
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen2.value) {
isOpen2.value = true;
activeIndex2.value = -1;
}
activeIndex2.value =
activeIndex2.value - 1 < 0 ? filtered2.value.length - 1 : activeIndex2.value - 1;
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex2.value >= 0 && activeIndex2.value < filtered2.value.length)
select2(filtered2.value[activeIndex2.value]);
} else if (e.key === "Escape") {
isOpen2.value = false;
activeIndex2.value = -1;
inputEl2.value?.blur();
}
}
watch(activeIndex1, (val) => {
nextTick(() => {
if (val >= 0 && listEl1.value) {
const item = listEl1.value.children[val];
item?.scrollIntoView({ block: "nearest" });
}
});
});
watch(activeIndex2, (val) => {
nextTick(() => {
if (val >= 0 && listEl2.value) {
const item = listEl2.value.children[val];
item?.scrollIntoView({ block: "nearest" });
}
});
});
function handleClickOutside(e) {
if (rootEl1.value && !rootEl1.value.contains(e.target)) {
isOpen1.value = false;
activeIndex1.value = -1;
}
if (rootEl2.value && !rootEl2.value.contains(e.target)) {
isOpen2.value = false;
activeIndex2.value = -1;
}
}
onMounted(() => document.addEventListener("click", handleClickOutside));
onUnmounted(() => document.removeEventListener("click", handleClickOutside));
function getItemColor(i, activeIdx, selectedVal, optValue) {
if (i === activeIdx) return "#38bdf8";
if (selectedVal === optValue) return "#38bdf8";
return "#94a3b8";
}
function getItemBg(i, activeIdx) {
return i === activeIdx ? "rgba(56,189,248,0.12)" : "transparent";
}
</script>
<template>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0a0a0a; font-family: Inter, system-ui, sans-serif; color: #f2f6ff; padding: 2rem;">
<div style="width: 100%; max-width: 640px;">
<h1 style="font-size: 1.5rem; font-weight: 800; margin-bottom: 0.375rem;">Combobox</h1>
<p style="color: #475569; font-size: 0.875rem; margin-bottom: 2rem;">Searchable dropdown select with keyboard navigation.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<!-- Framework -->
<div ref="rootEl1" style="position: relative;">
<label style="display: block; font-size: 0.75rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Framework</label>
<div style="position: relative;">
<input
ref="inputEl1"
type="text"
role="combobox"
:aria-expanded="isOpen1"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
v-model="query1"
placeholder="Search frameworks..."
@focus="isOpen1 = true; activeIndex1 = -1"
@input="isOpen1 = true"
@keydown="handleKey1"
style="width: 100%; padding: 0.625rem 2.25rem 0.625rem 0.875rem; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; color: #f2f6ff; font-size: 0.875rem; font-family: inherit; outline: none;"
/>
<span :style="{ position: 'absolute', right: '0.625rem', top: '50%', transform: `translateY(-50%) rotate(${isOpen1 ? 180 : 0}deg)`, color: '#4a4a4a', pointerEvents: 'none', transition: 'transform 0.2s' }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<ul
v-if="isOpen1"
ref="listEl1"
role="listbox"
style="position: absolute; top: calc(100% + 0.375rem); left: 0; right: 0; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; max-height: 14rem; overflow-y: auto; list-style: none; z-index: 50; padding: 0; margin: 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);"
>
<li v-if="filtered1.length === 0" style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: #4a4a4a; text-align: center;">No results found</li>
<li
v-else
v-for="(opt, i) in filtered1"
:key="opt.value"
role="option"
:aria-selected="selectedValue1 === opt.value"
@click="select1(opt)"
@mouseenter="activeIndex1 = i"
:style="{ padding: '0.5rem 0.875rem', fontSize: '0.875rem', color: getItemColor(i, activeIndex1, selectedValue1, opt.value), background: getItemBg(i, activeIndex1), cursor: 'pointer', fontWeight: selectedValue1 === opt.value ? 600 : 400, transition: 'background 0.1s, color 0.1s' }"
>
{{ opt.label }}
</li>
</ul>
</div>
<!-- Country -->
<div ref="rootEl2" style="position: relative;">
<label style="display: block; font-size: 0.75rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">Country</label>
<div style="position: relative;">
<input
ref="inputEl2"
type="text"
role="combobox"
:aria-expanded="isOpen2"
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
v-model="query2"
placeholder="Search countries..."
@focus="isOpen2 = true; activeIndex2 = -1"
@input="isOpen2 = true"
@keydown="handleKey2"
style="width: 100%; padding: 0.625rem 2.25rem 0.625rem 0.875rem; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; color: #f2f6ff; font-size: 0.875rem; font-family: inherit; outline: none;"
/>
<span :style="{ position: 'absolute', right: '0.625rem', top: '50%', transform: `translateY(-50%) rotate(${isOpen2 ? 180 : 0}deg)`, color: '#4a4a4a', pointerEvents: 'none', transition: 'transform 0.2s' }">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
<ul
v-if="isOpen2"
ref="listEl2"
role="listbox"
style="position: absolute; top: calc(100% + 0.375rem); left: 0; right: 0; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; max-height: 14rem; overflow-y: auto; list-style: none; z-index: 50; padding: 0; margin: 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);"
>
<li v-if="filtered2.length === 0" style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: #4a4a4a; text-align: center;">No results found</li>
<li
v-else
v-for="(opt, i) in filtered2"
:key="opt.value"
role="option"
:aria-selected="selectedValue2 === opt.value"
@click="select2(opt)"
@mouseenter="activeIndex2 = i"
:style="{ padding: '0.5rem 0.875rem', fontSize: '0.875rem', color: getItemColor(i, activeIndex2, selectedValue2, opt.value), background: getItemBg(i, activeIndex2), cursor: 'pointer', fontWeight: selectedValue2 === opt.value ? 600 : 400, transition: 'background 0.1s, color 0.1s' }"
>
{{ opt.label }}
</li>
</ul>
</div>
</div>
<p v-if="selectedFramework" style="font-size: 0.8125rem; color: #94a3b8;">
Selected framework: <strong style="color: #38bdf8;">{{ selectedFramework }}</strong>
</p>
</div>
</div>
</template><script>
import { onMount, onDestroy } from "svelte";
const frameworks = [
{ value: "react", label: "React" },
{ value: "vue", label: "Vue" },
{ value: "angular", label: "Angular" },
{ value: "svelte", label: "Svelte" },
{ value: "solid", label: "SolidJS" },
{ value: "astro", label: "Astro" },
{ value: "next", label: "Next.js" },
{ value: "nuxt", label: "Nuxt" },
{ value: "remix", label: "Remix" },
{ value: "qwik", label: "Qwik" },
];
const countries = [
{ value: "us", label: "United States" },
{ value: "ca", label: "Canada" },
{ value: "uk", label: "United Kingdom" },
{ value: "de", label: "Germany" },
{ value: "fr", label: "France" },
{ value: "jp", label: "Japan" },
{ value: "au", label: "Australia" },
{ value: "br", label: "Brazil" },
];
let selectedFramework = "";
// Combobox 1 state
let query1 = "";
let isOpen1 = false;
let activeIndex1 = -1;
let selectedValue1 = "";
let rootEl1;
let inputEl1;
let listEl1;
// Combobox 2 state
let query2 = "";
let isOpen2 = false;
let activeIndex2 = -1;
let selectedValue2 = "";
let rootEl2;
let inputEl2;
let listEl2;
$: filtered1 = frameworks.filter((o) => o.label.toLowerCase().includes(query1.toLowerCase()));
$: filtered2 = countries.filter((o) => o.label.toLowerCase().includes(query2.toLowerCase()));
function select1(opt) {
selectedValue1 = opt.value;
query1 = opt.label;
selectedFramework = opt.value;
isOpen1 = false;
activeIndex1 = -1;
}
function select2(opt) {
selectedValue2 = opt.value;
query2 = opt.label;
isOpen2 = false;
activeIndex2 = -1;
}
function handleKey1(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen1) {
isOpen1 = true;
activeIndex1 = -1;
}
activeIndex1 = activeIndex1 + 1 >= filtered1.length ? 0 : activeIndex1 + 1;
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen1) {
isOpen1 = true;
activeIndex1 = -1;
}
activeIndex1 = activeIndex1 - 1 < 0 ? filtered1.length - 1 : activeIndex1 - 1;
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex1 >= 0 && activeIndex1 < filtered1.length) select1(filtered1[activeIndex1]);
} else if (e.key === "Escape") {
isOpen1 = false;
activeIndex1 = -1;
inputEl1?.blur();
}
}
function handleKey2(e) {
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isOpen2) {
isOpen2 = true;
activeIndex2 = -1;
}
activeIndex2 = activeIndex2 + 1 >= filtered2.length ? 0 : activeIndex2 + 1;
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (!isOpen2) {
isOpen2 = true;
activeIndex2 = -1;
}
activeIndex2 = activeIndex2 - 1 < 0 ? filtered2.length - 1 : activeIndex2 - 1;
} else if (e.key === "Enter") {
e.preventDefault();
if (activeIndex2 >= 0 && activeIndex2 < filtered2.length) select2(filtered2[activeIndex2]);
} else if (e.key === "Escape") {
isOpen2 = false;
activeIndex2 = -1;
inputEl2?.blur();
}
}
$: if (activeIndex1 >= 0 && listEl1) {
const item = listEl1.children[activeIndex1];
item?.scrollIntoView({ block: "nearest" });
}
$: if (activeIndex2 >= 0 && listEl2) {
const item = listEl2.children[activeIndex2];
item?.scrollIntoView({ block: "nearest" });
}
function handleClickOutside(e) {
if (rootEl1 && !rootEl1.contains(e.target)) {
isOpen1 = false;
activeIndex1 = -1;
}
if (rootEl2 && !rootEl2.contains(e.target)) {
isOpen2 = false;
activeIndex2 = -1;
}
}
onMount(() => {
document.addEventListener("click", handleClickOutside);
});
onDestroy(() => {
document.removeEventListener("click", handleClickOutside);
});
function getItemColor(i, activeIdx, selectedVal, optValue) {
if (i === activeIdx) return "#38bdf8";
if (selectedVal === optValue) return "#38bdf8";
return "#94a3b8";
}
function getItemBg(i, activeIdx) {
return i === activeIdx ? "rgba(56,189,248,0.12)" : "transparent";
}
</script>
<div style="min-height: 100vh; display: flex; align-items: center; justify-content: center; background: #0a0a0a; font-family: Inter, system-ui, sans-serif; color: #f2f6ff; padding: 2rem;">
<div style="width: 100%; max-width: 640px;">
<h1 style="font-size: 1.5rem; font-weight: 800; margin-bottom: 0.375rem;">Combobox</h1>
<p style="color: #475569; font-size: 0.875rem; margin-bottom: 2rem;">Searchable dropdown select with keyboard navigation.</p>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<!-- Framework combobox -->
<div bind:this={rootEl1} style="position: relative;">
<label style="display: block; font-size: 0.75rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">
Framework
</label>
<div style="position: relative;">
<input
bind:this={inputEl1}
type="text"
role="combobox"
aria-expanded={isOpen1}
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
bind:value={query1}
placeholder="Search frameworks..."
on:focus={() => { isOpen1 = true; activeIndex1 = -1; }}
on:input={() => { isOpen1 = true; }}
on:keydown={handleKey1}
style="width: 100%; padding: 0.625rem 2.25rem 0.625rem 0.875rem; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; color: #f2f6ff; font-size: 0.875rem; font-family: inherit; outline: none;"
/>
<span style="position: absolute; right: 0.625rem; top: 50%; transform: translateY(-50%) rotate({isOpen1 ? 180 : 0}deg); color: #4a4a4a; pointer-events: none; transition: transform 0.2s;">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
{#if isOpen1}
<ul
bind:this={listEl1}
role="listbox"
style="position: absolute; top: calc(100% + 0.375rem); left: 0; right: 0; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; max-height: 14rem; overflow-y: auto; list-style: none; z-index: 50; padding: 0; margin: 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);"
>
{#if filtered1.length === 0}
<li style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: #4a4a4a; text-align: center;">No results found</li>
{:else}
{#each filtered1 as opt, i}
<li
role="option"
aria-selected={selectedValue1 === opt.value}
on:click={() => select1(opt)}
on:mouseenter={() => { activeIndex1 = i; }}
style="padding: 0.5rem 0.875rem; font-size: 0.875rem; color: {getItemColor(i, activeIndex1, selectedValue1, opt.value)}; background: {getItemBg(i, activeIndex1)}; cursor: pointer; font-weight: {selectedValue1 === opt.value ? 600 : 400}; transition: background 0.1s, color 0.1s;"
>
{opt.label}
</li>
{/each}
{/if}
</ul>
{/if}
</div>
<!-- Country combobox -->
<div bind:this={rootEl2} style="position: relative;">
<label style="display: block; font-size: 0.75rem; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem;">
Country
</label>
<div style="position: relative;">
<input
bind:this={inputEl2}
type="text"
role="combobox"
aria-expanded={isOpen2}
aria-autocomplete="list"
aria-haspopup="listbox"
autocomplete="off"
bind:value={query2}
placeholder="Search countries..."
on:focus={() => { isOpen2 = true; activeIndex2 = -1; }}
on:input={() => { isOpen2 = true; }}
on:keydown={handleKey2}
style="width: 100%; padding: 0.625rem 2.25rem 0.625rem 0.875rem; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; color: #f2f6ff; font-size: 0.875rem; font-family: inherit; outline: none;"
/>
<span style="position: absolute; right: 0.625rem; top: 50%; transform: translateY(-50%) rotate({isOpen2 ? 180 : 0}deg); color: #4a4a4a; pointer-events: none; transition: transform 0.2s;">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
</div>
{#if isOpen2}
<ul
bind:this={listEl2}
role="listbox"
style="position: absolute; top: calc(100% + 0.375rem); left: 0; right: 0; background: #141414; border: 1px solid #2a2a2a; border-radius: 0.625rem; max-height: 14rem; overflow-y: auto; list-style: none; z-index: 50; padding: 0; margin: 0; box-shadow: 0 8px 24px rgba(0,0,0,0.4);"
>
{#if filtered2.length === 0}
<li style="padding: 0.75rem 0.875rem; font-size: 0.8125rem; color: #4a4a4a; text-align: center;">No results found</li>
{:else}
{#each filtered2 as opt, i}
<li
role="option"
aria-selected={selectedValue2 === opt.value}
on:click={() => select2(opt)}
on:mouseenter={() => { activeIndex2 = i; }}
style="padding: 0.5rem 0.875rem; font-size: 0.875rem; color: {getItemColor(i, activeIndex2, selectedValue2, opt.value)}; background: {getItemBg(i, activeIndex2)}; cursor: pointer; font-weight: {selectedValue2 === opt.value ? 600 : 400}; transition: background 0.1s, color 0.1s;"
>
{opt.label}
</li>
{/each}
{/if}
</ul>
{/if}
</div>
</div>
{#if selectedFramework}
<p style="font-size: 0.8125rem; color: #94a3b8;">
Selected framework: <strong style="color: #38bdf8;">{selectedFramework}</strong>
</p>
{/if}
</div>
</div>Combobox
A searchable dropdown select component with type-to-filter, full keyboard navigation, and accessible markup.
Features
- Type to filter options in real-time
- Keyboard navigation: Arrow Up/Down, Enter to select, Escape to close
- Accessible ARIA roles and attributes
- Highlighted active item with visual focus indicator
- Click outside to close
- Dark theme styling