A custom select: a role="combobox" text field that opens a filtered ListBox in a Popover. It pairs three existing primitives — the Input shell, the Popover positioning layer, and the ListBox option engine — behind one headless connectCombobox core, so the same component supports single and multiple selection, typeahead search, a clear button, and creatable values.
Unlike the standalone ListBox (which moves DOM focus between options with a roving tabindex), the Combobox keeps focus on the input and drives the list with aria-activedescendant (the ListBox’s focusStrategy="active-descendant" mode). The input owns the keyboard; the popup just reflects the cursor.
Single selection. Type to filter, arrow keys to move the cursor (focus stays in the input), Enter to commit. Selecting an option fills the field with its label and closes the popup.
Selected: none
---
// GENERATED from packages/demos/combobox/default.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
import type { Key } from "@pindoba/core-combobox";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
{ id: "qwik", label: "Qwik" },
];
const selected = null as Key | null;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="combobox/default"
>
<Combobox
items={items}
aria-label="Framework"
placeholder="Search a framework…"
value={null as Key | null}
data-ir="1"
/>
<p>
Selected: <!--ir:2-->
{selected ?? "none"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/default.ts";
document
.querySelectorAll('[data-ir-root="combobox/default"]')
.forEach((root) => hydrate([root]));
</script>Set selectionMode="multiple". Each chosen value renders as a removable chip (Group → Badge + a dismiss Button) inside the field; the popup stays open and the query clears after each pick. Backspace on an empty query removes the last chip.
Selected: svelte
---
// GENERATED from packages/demos/combobox/multi-select.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
import type { Key } from "@pindoba/core-combobox";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
];
const selected = ["svelte"] as Key[];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="combobox/multi-select"
>
<Combobox
items={items}
selectionMode="multiple"
aria-label="Frameworks"
placeholder="Pick frameworks…"
value={["svelte"] as Key[]}
data-ir="1"
/>
<p>
Selected: <!--ir:2-->
{selected.join(", ") || "none"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/multi-select.ts";
document
.querySelectorAll('[data-ir-root="combobox/multi-select"]')
.forEach((root) => hydrate([root]));
</script>size (sm · md · lg) sets the field height to the matching control.<size> — identical to Input and every other control. The field stays exactly that tall in every state, chips included; the chips themselves scale a tier with the size so they stay proportionate rather than floating small in a taller field.
---
// GENERATED from packages/demos/combobox/sizes.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
import type { Key } from "@pindoba/core-combobox";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
];
const sizes = ["sm", "md", "lg"] as const;
const selected = {
sm: ["svelte"],
md: ["svelte", "react"],
lg: ["svelte", "react"],
} as Record<(typeof sizes)[number], Key[]>;
---
<div
class={stack({ gap: "md", alignItems: "flex-start", width: "100%" })}
data-ir-root="combobox/sizes"
>
<!--ir:1-->
{sizes.map((size) => (
<Combobox
items={items}
size={size}
selectionMode="multiple"
chipOverflow={false}
aria-label={`Frameworks (${size})`}
placeholder={`Pick frameworks (${size})…`}
value={selected[size]}
data-ir="2"
data-ir-key={size}
/>
))}
<!--/ir-->
</div>
<script>
import { hydrate } from "./_client/sizes.ts";
document
.querySelectorAll('[data-ir-root="combobox/sizes"]')
.forEach((root) => hydrate([root]));
</script>When many values are selected the chip row can grow taller than the field has room for. Cap it with chipOverflow (defaults to 1):
chipOverflow={n} — keep the last n chips inline and collapse the rest behind a “+N” chip. (Last, not first, so the most-recent chip stays next to the input and Backspace still deletes it.)chipOverflow="auto" — measure the field and fit as many chips as the width allows. Best with a constrained width (fullWidth or a max-width); resize the second field below to watch it adapt.chipOverflow={false} — disable collapsing entirely: every selected chip renders inline and the field grows to fit them.The “+N” chip is a Badge plus a chevron Button (like the closable chip). The chevron opens a popover listing the collapsed options with a (keyboard-operable) remove button — manage the hidden selections without expanding the field. The “+N” popover and the options dropdown are mutually exclusive: opening one closes the other.
Long labels can be trimmed independently of overflow: chipMaxChars={n} truncates to n characters (the full label stays in the chip’s title and in the popover), and chipMaxWidth="8rem" caps the chip width with an ellipsis. They compose.
---
// GENERATED from packages/demos/combobox/chip-overflow.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
import type { Key } from "@pindoba/core-combobox";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
{ id: "qwik", label: "Qwik" },
{ id: "ember", label: "Ember.js (with a deliberately long label)" },
{ id: "preact", label: "Preact" },
];
const fill = { root: { style: { width: "100%" } } };
const fixedBox = css({ width: "360px", maxWidth: "100%" });
const autoBox = css({
resize: "horizontal",
overflow: "hidden",
width: "440px",
maxWidth: "100%",
padding: "8px",
});
---
<div class={stack({ gap: "lg", alignItems: "flex-start" })}>
<div class={stack({ gap: "2xs", alignItems: "flex-start" })}>
<span>
Fixed — last 1 inline, "+N" for the rest, labels capped at 10 chars
</span>
<div class={fixedBox}>
<Combobox
items={items}
selectionMode="multiple"
chipOverflow={1}
chipMaxChars={10}
aria-label="Frameworks (fixed overflow)"
placeholder="Pick frameworks…"
value={["react", "svelte", "vue", "solid", "angular"] as Key[]}
passThrough={fill}
/>
</div>
</div>
<div class={stack({ gap: "2xs", alignItems: "flex-start" })}>
<span>Auto — fit chips to the field width (resize the box), 8rem cap</span>
<div class={autoBox}>
<Combobox
items={items}
selectionMode="multiple"
chipOverflow="auto"
chipMaxWidth="8rem"
aria-label="Frameworks (auto overflow)"
placeholder="Pick frameworks…"
value={["react", "svelte", "vue", "ember"] as Key[]}
passThrough={fill}
/>
</div>
</div>
</div>With allowCreate, typing a value that isn’t in the list surfaces a “Create …” row at the bottom. Committing it (click or Enter) fires onCreate so you can add the value to your items. In multiple mode the combobox has already selected it — one onValueChange with the new list — so onCreate only needs to extend items; in single mode the value stays in the field for you to add and select.
Selected: none
---
// GENERATED from packages/demos/combobox/creatable.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "bug", label: "bug" },
{ id: "feature", label: "feature" },
{ id: "docs", label: "docs" },
];
const selected = [] as string[];
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="combobox/creatable"
>
<Combobox
items={items}
selectionMode="multiple"
allowCreate
aria-label="Labels"
placeholder="Add or create a label…"
value={[] as string[]}
data-ir="1"
/>
<p>
Selected: <!--ir:2-->
{selected.join(", ") || "none"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/creatable.ts";
document
.querySelectorAll('[data-ir-root="combobox/creatable"]')
.forEach((root) => hydrate([root]));
</script>For fields that collect free-form values — email recipients, tags — a multiple, creatable combobox takes four extra props:
parseCreate turns typed or pasted text into the value to create (here: lowercase, and Name <address> becomes address). Returning null rejects the text and leaves it in the field.delimiters split the text: typing one commits what came before it, and a paste becomes one value per piece. A piece naming an existing option selects it instead of creating a duplicate. Leave space out when values can contain one — it would split Name <address> in two.validateChip returns false or an error message to mark a chip invalid. It turns danger, and the message becomes its tooltip and part of its remove button’s label.chipBackspace="edit" makes Backspace in an empty field put the last chip back as text to fix, instead of dropping it.Try pasting ravi@example.com, Sam <SAM@example.com>; not-an-address.
To: ana@example.com
---
// GENERATED from packages/demos/combobox/multi-entry.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
const contacts = [
{
id: "ana@example.com",
label: "Ana Lima",
description: "ana@example.com",
},
{
id: "ravi@example.com",
label: "Ravi Shah",
description: "ravi@example.com",
},
];
const recipients = ["ana@example.com"] as string[];
function parseAddress(text: string) {
const angled = /<([^>]+)>/.exec(text);
return (angled ? angled[1]! : text).trim().toLowerCase() || null;
}
function checkAddress(value: string | number) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(String(value))
? true
: "Not a valid email address";
}
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="combobox/multi-entry"
>
<Combobox
items={contacts}
selectionMode="multiple"
allowCreate
chipOverflow={false}
delimiters={[",", ";", "\n"]}
chipBackspace="edit"
parseCreate={parseAddress}
validateChip={checkAddress}
aria-label="To"
placeholder="Type or paste addresses…"
value={["ana@example.com"] as string[]}
data-ir="1"
/>
<p>
To: <!--ir:2-->
{recipients.join(", ") || "nobody"}
<!--/ir-->
</p>
</div>
<script>
import { hydrate } from "./_client/multi-entry.ts";
document
.querySelectorAll('[data-ir-root="combobox/multi-entry"]')
.forEach((root) => hydrate([root]));
</script>In Astro, delimiters and chipBackspace ride in the server config, but parseCreate and validateChip are functions: hand them to the client runtime with comboboxHydration.get(input)?.update({ parseCreate, validateChip }).
Pass a leading snippet (Svelte) or leading slot (Astro) to render an icon inside the field — e.g. a search glyph.
---
// GENERATED from packages/demos/combobox/with-icons.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import Stamp from "@pindoba/astro-stamp";
import { Search } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{
id: "react",
label: "React",
description: "A library for web and native UIs",
},
{
id: "svelte",
label: "Svelte",
description: "Cybernetically enhanced web apps",
},
{ id: "vue", label: "Vue", description: "The progressive framework" },
{
id: "solid",
label: "Solid",
description: "Simple and performant reactivity",
},
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox items={items} aria-label="Framework" placeholder="Search…">
<Stamp slot="leading" emphasis="ghost">
<Search size={16} />
</Stamp>
</Combobox>
</div>Group related options with section nodes (type: "section"). Filtering keeps section structure and hides any section that loses all its matches.
---
// GENERATED from packages/demos/combobox/grouped.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
import type { ListBoxNodeInput } from "@pindoba/core-combobox";
const items = [
{
id: "frontend",
type: "section",
title: "Frontend",
items: [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
],
},
{
id: "backend",
type: "section",
title: "Backend",
items: [
{ id: "node", label: "Node.js" },
{ id: "deno", label: "Deno" },
{ id: "bun", label: "Bun" },
],
},
] as ListBoxNodeInput[];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox items={items} aria-label="Technology" placeholder="Search…" />
</div>Set filter={false} to turn off built-in filtering and feed your own results: handle onInputChange, fetch, and update items (toggle isLoading while in flight). The Svelte demo debounces a fake fetch; the Astro tab shows the loading state (true async needs a client island).
---
// GENERATED from packages/demos/combobox/async.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [] as { id: string; label: string }[];
const isLoading = false;
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="combobox/async"
>
<Combobox
items={items}
isLoading={isLoading}
filter={false}
aria-label="Fruit (async)"
placeholder="Type to search…"
data-ir="1"
/>
</div>
<script>
import { hydrate } from "./_client/async.ts";
document
.querySelectorAll('[data-ir-root="combobox/async"]')
.forEach((root) => hydrate([root]));
</script>Mark an option disabled, or list its key in disabledKeys. Disabled options are skipped by arrow navigation and can’t be selected.
---
// GENERATED from packages/demos/combobox/disabled.demo.tsx — edit the source, not this file.
import Combobox from "@pindoba/astro-combobox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue", disabled: true },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular", disabled: true },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
items={items}
aria-label="Framework"
placeholder="Some options disabled…"
/>
</div>The floating list is a ListBox whose container is a Panel root. Combobox exposes it through four props — listboxRadius, listboxShadow, listboxBorder and listboxBackground — which map onto the list’s Panel radius / shadow / border / background.
Set the corner explicitly rather than reaching for radius="inner" here: the list is mounted inside a transparent, unpadded popover <dialog>, so inner would resolve against that dialog’s xl corner instead of anything meaningful. The defaults are listboxRadius="md", listboxBorder="muted" and listboxBackground="surface.peak", with the floating lg elevation coming from the listbox recipe.
<Combobox listboxRadius="lg" listboxBorder="default" items={items} />
Focus stays on the input throughout.
| Key | Action |
|---|---|
| Type | Filters the options and opens the popup |
↓ / ↑ | Open the popup, then move the active option |
Home / End | Jump to the first / last option |
Enter | Select the active option (or commit the “Create …” row) |
Escape | Close the popup |
Backspace | Remove the last chip when the query is empty (multiselect; with chipBackspace="edit", put it back in the field as text) |
Tab | Move focus away and close the popup |
role="combobox" with aria-expanded, aria-controls (the listbox id), aria-haspopup="listbox", and aria-autocomplete="list".id lands on the inner <input>, so a plain <label for={id}> focuses the combobox (the input is natively labelable) — no Field wrapper required.aria-activedescendant; DOM focus never leaves the input.role="listbox" of role="option" rows with aria-selected.tabindex="-1"); chip remove buttons are labelled Remove <value>.