component
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.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
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" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
id="astro-cb-default"
items={items}
aria-label="Framework"
placeholder="Search a framework…"
/>
</div>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.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
id="astro-cb-multi"
items={items}
selectionMode="multiple"
defaultValue={["svelte"]}
aria-label="Frameworks"
placeholder="Pick frameworks…"
/>
</div>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.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "react", label: "React" },
{ id: "svelte", label: "Svelte" },
{ id: "vue", label: "Vue" },
{ id: "solid", label: "Solid" },
{ id: "angular", label: "Angular" },
];
// Each field stays exactly `control.<size>` tall; the chips scale with it.
const sizes = ["sm", "md", "lg"] as const;
const seed: Record<(typeof sizes)[number], string[]> = {
sm: ["svelte"],
md: ["svelte", "react"],
lg: ["svelte", "react"],
};
---
<div class={stack({ gap: "md", alignItems: "flex-start", width: "100%" })}>
{
sizes.map((size) => (
<Combobox
id={`astro-cb-sizes-${size}`}
items={items}
size={size}
selectionMode="multiple"
chipOverflow={false}
defaultValue={seed[size]}
aria-label={`Frameworks (${size})`}
placeholder={`Pick frameworks (${size})…`}
/>
))
}
</div>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.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
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%" } } };
---
<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 style="width: 360px; max-width: 100%;">
<Combobox
id="astro-cb-overflow-fixed"
items={items}
selectionMode="multiple"
chipOverflow={1}
chipMaxChars={10}
defaultValue={["react", "svelte", "vue", "solid", "angular"]}
aria-label="Frameworks (fixed overflow)"
placeholder="Pick frameworks…"
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>
<!-- `resize` needs a non-visible overflow; the padding keeps that clip off
the field's focus ring. -->
<div
style="resize: horizontal; overflow: hidden; width: 440px; max-width: 100%; padding: 8px;"
>
<Combobox
id="astro-cb-overflow-auto"
items={items}
selectionMode="multiple"
chipOverflow="auto"
chipMaxWidth="8rem"
defaultValue={["react", "svelte", "vue", "ember"]}
aria-label="Frameworks (auto overflow)"
placeholder="Pick frameworks…"
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 data.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "bug", label: "bug" },
{ id: "feature", label: "feature" },
{ id: "docs", label: "docs" },
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
id="astro-cb-creatable"
items={items}
selectionMode="multiple"
allowCreate
aria-label="Labels"
placeholder="Add or create a label…"
/>
</div>Pass a leading snippet (Svelte) or leading slot (Astro) to render an icon inside the field — e.g. a search glyph.
---
import Combobox from "../combobox.astro";
import { Search } from "@lucide/astro";
import Stamp from "@pindoba/astro-stamp";
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
id="astro-cb-icons"
items={items}
aria-label="Framework"
placeholder="Search…"
>
<Stamp emphasis="ghost" slot="leading"><Search /></Stamp>
</Combobox>
</div>Group related options with section nodes (type: "section"). Filtering keeps section structure and hides any section that loses all its matches.
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
import type { ListBoxNodeInput } from "@pindoba/core-combobox";
const items: ListBoxNodeInput[] = [
{
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" },
],
},
];
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
id="astro-cb-grouped"
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).
---
import Combobox from "../combobox.astro";
import { stack } from "@pindoba/styled-system/patterns";
// Astro renders on the server, so this demo shows the loading state. True
// async (fetch-on-type with `filter={false}` + `onInputChange`) needs a
// client island — see the Svelte demo for the interactive version.
---
<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
<Combobox
id="astro-cb-async"
items={[]}
isLoading
aria-label="Fruit (async)"
placeholder="Type to search…"
/>
</div>Mark an option disabled, or list its key in disabledKeys. Disabled options are skipped by arrow navigation and can’t be selected.
---
import Combobox from "../combobox.astro";
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
id="astro-cb-disabled"
items={items}
aria-label="Framework"
placeholder="Some options disabled…"
/>
</div>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) |
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>.falseShow a "Create …" row for a typed value that isn't in the list.
Truncate inline chip labels to this many characters (appending `…`). The full label is kept in the chip's `title` and shown un-truncated inside the overflow popover. Composes with `chipMaxWidth`.
CSS `max-width` (e.g. `"8rem"`) applied to inline chip labels with ellipsis truncation. Composes with `chipMaxChars`.
1Cap the inline chip footprint in multiselect. A `number` shows the last N selected chips inline and collapses the rest behind a "+N" overflow chip; `"auto"` measures the field and fits as many chips as the width allows; `false` disables collapsing entirely so every chip renders inline (the field grows). The framework wrappers default this to `1`; pass `false` to opt out. The "+N" chip opens a popover listing all collapsed options with remove buttons.
Initial selection (uncontrolled). Single: a key or `null`; multiple: keys.
Keys that cannot be selected or focused.
Bindable reference to the inner `<input>`.
Content shown when there are no matches.
Validation feedback styling for the field.
true`true` uses the built-in case-insensitive substring filter; `false` disables it (feed your own `items`); or pass a `(optionText, query) => boolean` predicate.
Stable id prefix; sub-element ids derive from it.
Bindable query text (what the user typed — distinct from the selection).
falseShow the loading row instead of options (for async fetches).
The options to choose from, in render order. An option is `{ id, label, description?, eyebrow?, leading?, trailing?, textValue?, disabled? }`; a section is `{ id, type: "section", title?, items }`. For async/remote, feed your own pre-filtered list and set `filter={false}`.
Leading content inside the field (e.g. a search icon).
Content shown while `isLoading`.
Fires when a free-text value is committed (`allowCreate`).
Fires when the query text changes (use with `filter={false}` for async).
Fires when the open state changes.
Fires when the selection changes.
Bindable open state.
Per-slot style / attribute override bag for customizing any rendered slot or composed child component.
Placeholder text for the input.
"bottom-start"Floating-UI placement of the popup.
"single"`"single"` commits a label and closes; `"multiple"` toggles removable chips and keeps the popup open.
"md"Field and option size.
Bindable current selection. Single: a key or `null`; multiple: an array of keys.
Plus all standard <input> HTML
attributes.