Combobox
Free · MITHeadless, accessible, and styled entirely through the attribute surface below — bring your own CSS system, or start from the reference theme.
Install
Without a flag you get no styling at all — plain HTML carrying the attribute surface below. --theme copies the same theme in two dialects;--bridge lumos re-points either one onto Lumos for Astro's tokens (it needs a theme to re-point, and Lumos itself, already in your project).
Unstyled — behavior only, you write the CSS
npx astrocraft-ui add forms/comboboxPlain-CSS theme — no build step
npx astrocraft-ui add forms/combobox --theme cssTailwind theme — needs Tailwind v4
npx astrocraft-ui add forms/combobox --theme tailwindPlain-CSS theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/combobox --theme css --bridge lumosTailwind theme, re-pointed onto Lumos tokens
npx astrocraft-ui add forms/combobox --theme tailwind --bridge lumosLive demo
ComboBox (Autocomplete)
- Astro
- Next.js
- Nuxt
- Remix
- SvelteKit
- SolidStart
- Qwik City
- Eleventy
- No results
Attribute surface
With no class-merging machinery, these attributes are the whole extension point. State is a data-attribute or a native attribute; visibility is the hiddenattribute. Generated from source by pnpm slots.
| Component | Slots | Variants | Runtime state | Native state |
|---|---|---|---|---|
| `ComboBox.astro` | `chevron` `combobox` `combobox-empty` `combobox-input` `combobox-list` `combobox-toggle` `combobox-value` | `data-size`: `sm` · `md` · `lg` `data-state`: `default` · `error` · `success` | `data-selected` | `[aria-expanded]` `[hidden]` |
| `ComboBoxOption.astro` | `combobox-check` `combobox-option` `combobox-option-label` | — | — | `[aria-selected]` |
Source
What the command copies — 3 files, alias-free so the copy lands standing alone. This is the whole component; there is nothing else.
---
// src/components/ui/forms/combobox/ComboBox.astro — headless primitive (see ../../README.md).
// Single-select autocomplete: a role="combobox" text input over a role="listbox" of <ComboBoxOption>s
// that filters as you type. Keyboard: ↑/↓ move the active option (via aria-activedescendant, focus
// stays in the input), Enter commits, Esc closes, Home/End jump. The visible input shows the label;
// when `name` is set, a hidden input carries the selected option's value so the form submits the
// value, not the label. The input carries the shared `data-size` / `data-state` field surface.
//
// The list is shown and hidden with the `hidden` ATTRIBUTE — never a class. That only wins over a
// theme's own `display` rule because structure.css declares `[hidden] { display: none !important }`;
// see the note in that file before you style the list's display.
//
// ponytail: client-side, static options only — the filter is a substring match on each option's text,
// there's no async/remote loading and no grouping. Upgrade path: fetch + render options server-side
// per page, or add a remote-source branch to the script below; the markup contract stays the same.
// The listbox is positioned inside the root wrapper, so an ancestor with `overflow: hidden` can clip
// it — lift it to the Popover API (like Dropdown) if a layout needs that.
import "../../../../styles/structure.css";
import type { HTMLAttributes } from "astro/types";
import Chevron from "../../_Chevron.astro";
type Props = HTMLAttributes<"input"> & {
size?: "sm" | "md" | "lg";
state?: "default" | "error" | "success";
emptyText?: string;
};
const {
size = "md",
state = "default",
name,
placeholder = "Search…",
emptyText = "No results",
class: className,
...rest
} = Astro.props;
const listId = `combobox-list-${crypto.randomUUID().slice(0, 8)}`;
---
<div data-slot="combobox">
<input
type="text"
role="combobox"
aria-expanded="false"
aria-controls={listId}
aria-autocomplete="list"
autocomplete="off"
placeholder={placeholder}
data-slot="combobox-input"
data-size={size}
data-state={state}
class={className}
{...rest}
/>
{
/* Carries the committed option's value for form submission — the visible input holds the label. */
}
{name && <input type="hidden" name={name} data-slot="combobox-value" />}
<button
type="button"
tabindex="-1"
aria-label="Toggle options"
aria-expanded="false"
data-slot="combobox-toggle"
>
<slot name="chevron"><Chevron /></slot>
</button>
<ul id={listId} role="listbox" data-slot="combobox-list" hidden>
<slot />
<li data-slot="combobox-empty" role="presentation" hidden>{emptyText}</li>
</ul>
</div>
<script>
import { createActiveDescendant, filterByText } from "../../_listbox";
import { onReadyOnce } from "../../_once";
let uid = 0;
function wire(root: HTMLElement) {
const input = root.querySelector<HTMLInputElement>('[data-slot="combobox-input"]');
const toggle = root.querySelector<HTMLButtonElement>('[data-slot="combobox-toggle"]');
const list = root.querySelector<HTMLElement>('[data-slot="combobox-list"]');
const empty = root.querySelector<HTMLElement>('[data-slot="combobox-empty"]');
const hidden = root.querySelector<HTMLInputElement>('[data-slot="combobox-value"]');
if (!input || !list) return;
const options = [...list.querySelectorAll<HTMLElement>('[data-slot="combobox-option"]')];
options.forEach((o) => (o.id ||= `combobox-opt-${(uid += 1)}`));
const rover = createActiveDescendant(input, options);
const open = () => {
list.hidden = false;
input.setAttribute("aria-expanded", "true");
toggle?.setAttribute("aria-expanded", "true");
};
const close = () => {
list.hidden = true;
input.setAttribute("aria-expanded", "false");
toggle?.setAttribute("aria-expanded", "false");
rover.setActive(null);
};
// Typing filters by the query; opening (focus / toggle) shows every option so a committed value
// can still be changed — filtering on open would leave only the one already-selected label visible.
const filter = () => {
filterByText(options, input.value, empty);
if (rover.active()?.hidden) rover.setActive(null);
};
const showAll = () => filterByText(options, "", empty);
const commit = (option: HTMLElement) => {
input.value = option.dataset.label ?? (option.textContent ?? "").trim();
if (hidden) hidden.value = option.dataset.value ?? input.value;
options.forEach((o) => (o.dataset.selected = String(o === option)));
close();
// `change` = value committed (what form frameworks listen for). Don't dispatch a synthetic
// `input` too — that's the user-typing signal and would re-enter the input handler below.
input.dispatchEvent(new Event("change", { bubbles: true }));
input.focus();
};
input.addEventListener("focus", () => {
input.select(); // select the committed label so the next keystroke replaces it
showAll();
open();
});
input.addEventListener("input", () => {
open();
filter();
rover.setActive(rover.visible()[0] ?? null);
});
input.addEventListener("keydown", (event) => {
const vis = rover.visible();
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (input.getAttribute("aria-expanded") !== "true") open();
rover.move(1);
break;
case "ArrowUp":
event.preventDefault();
rover.move(-1);
break;
case "Home":
if (vis.length) {
event.preventDefault();
rover.setActive(vis[0]);
}
break;
case "End":
if (vis.length) {
event.preventDefault();
rover.setActive(vis[vis.length - 1]);
}
break;
case "Enter": {
const a = rover.active();
if (a) {
event.preventDefault();
commit(a);
}
break;
}
case "Escape":
close();
break;
}
});
toggle?.addEventListener("click", () => {
if (input.getAttribute("aria-expanded") === "true") {
close();
} else {
showAll();
open();
input.focus();
}
});
for (const o of options) {
// mousedown fires before the input's blur, so preventDefault keeps focus in the input.
o.addEventListener("mousedown", (event) => event.preventDefault());
o.addEventListener("click", () => commit(o));
o.addEventListener("mousemove", () => rover.setActive(o));
}
root.addEventListener("focusout", (event) => {
if (!root.contains(event.relatedTarget as Node)) close();
});
}
onReadyOnce('[data-slot="combobox"]', wire);
</script>
---
// src/components/ui/forms/combobox/ComboBoxOption.astro — ComboBox compound part (see ../../README.md).
// One role="option". The slotted content is both the label and what the filter matches on; pass
// `value` for a distinct stored value and `label` to override the text written into the input.
// The ComboBox script marks the committed option with `data-selected="true"` and the active one with
// `aria-selected="true"` — a theme reveals the check glyph off whichever it wants to signal.
import type { HTMLAttributes } from "astro/types";
type Props = HTMLAttributes<"li"> & { value?: string; label?: string };
const { value, label, class: className, ...rest } = Astro.props;
---
<li
role="option"
aria-selected="false"
data-slot="combobox-option"
data-value={value}
data-label={label}
class={className}
{...rest}
>
<span data-slot="combobox-option-label"><slot /></span>
<slot name="indicator"
><svg
data-slot="combobox-check"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 6 9 17l-5-5"></path>
</svg></slot
>
</li>
import ComboBox from "./ComboBox.astro";
import ComboBoxOption from "./ComboBoxOption.astro";
export { ComboBox, ComboBoxOption };
export default ComboBox;
What you get
The component source, copied into your project by npx astrocraft-ui add forms/combobox — no package dependency, no CSS to fight. Whatever the paid blocks compose, this is it.