component

Combobox

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.

Default

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.

  • React

  • Svelte

  • Vue

  • Solid

  • Angular

  • Qwik

---
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>

Multiple selection

Set selectionMode="multiple". Each chosen value renders as a removable chip (GroupBadge + 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.

    Svelte
    • React

    • Svelte

    • Vue

    • Solid

    • Angular

    ---
    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>

    Sizes

    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.

    Svelte
    • React

    • Svelte

    • Vue

    • Solid

    • Angular

    Svelte
    React
    • React

    • Svelte

    • Vue

    • Solid

    • Angular

    Svelte
    React
    • React

    • Svelte

    • Vue

    • Solid

    • Angular

    ---
    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>

    Chip overflow

    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.

    Fixed — last 1 inline, "+N" for the rest, labels capped at 10 chars
    +4
    • React
    • Svelte
    • Vue
    • Solid
    Angular
    • React

    • Svelte

    • Vue

    • Solid

    • Angular

    • Qwik

    • Ember.js (with a deliberately long label)

    • Preact

    Auto — fit chips to the field width (resize the box), 8rem cap
      React
      Svelte
      Vue
      Ember.js (…
      • React

      • Svelte

      • Vue

      • Solid

      • Angular

      • Qwik

      • Ember.js (with a deliberately long label)

      • Preact

      ---
      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>

      Creatable

      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.

        • bug

        • feature

        • docs

        • Create

        ---
        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>

        Leading content

        Pass a leading snippet (Svelte) or leading slot (Astro) to render an icon inside the field — e.g. a search glyph.

        • React

          A library for web and native UIs

        • Svelte

          Cybernetically enhanced web apps

        • Vue

          The progressive framework

        • Solid

          Simple and performant reactivity

        ---
        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>

        Sections

        Group related options with section nodes (type: "section"). Filtering keeps section structure and hides any section that loses all its matches.

        • Frontend
        • Backend
        ---
        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>

        Async / remote data

        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).

        • Loading…
        ---
        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>

        Disabled options

        Mark an option disabled, or list its key in disabledKeys. Disabled options are skipped by arrow navigation and can’t be selected.

        • React

        • Svelte

        • Vue

        • Solid

        • Angular

        ---
        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>

        Keyboard

        Focus stays on the input throughout.

        KeyAction
        TypeFilters the options and opens the popup
        / Open the popup, then move the active option
        Home / EndJump to the first / last option
        EnterSelect the active option (or commit the “Create …” row)
        EscapeClose the popup
        BackspaceRemove the last chip when the query is empty (multiselect)
        TabMove focus away and close the popup

        Accessibility

        • The input is a role="combobox" with aria-expanded, aria-controls (the listbox id), aria-haspopup="listbox", and aria-autocomplete="list".
        • The consumer id lands on the inner <input>, so a plain <label for={id}> focuses the combobox (the input is natively labelable) — no Field wrapper required.
        • The active option is surfaced via aria-activedescendant; DOM focus never leaves the input.
        • The popup is a role="listbox" of role="option" rows with aria-selected.
        • The clear and chevron buttons are labelled and removed from the tab order (tabindex="-1"); chip remove buttons are labelled Remove <value>.
        props · 27 shown · 27 total
        allowCreate
        boolean
        default false

        Show a "Create …" row for a typed value that isn't in the list.

        chipMaxChars
        number

        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`.

        chipMaxWidth
        string

        CSS `max-width` (e.g. `"8rem"`) applied to inline chip labels with ellipsis truncation. Composes with `chipMaxChars`.

        chipOverflow
        numberfalse"auto"
        default 1

        Cap 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.

        defaultValue svelte
        ComboboxValue

        Initial selection (uncontrolled). Single: a key or `null`; multiple: keys.

        disabledKeys svelte
        Iterable<Key>

        Keys that cannot be selected or focused.

        element binding svelte
        HTMLInputElementnull

        Bindable reference to the inner `<input>`.

        empty slot svelte
        Snippet

        Content shown when there are no matches.

        feedback
        "primary""neutral""success""warning""danger""inherit"

        Validation feedback styling for the field.

        filter
        boolean(optionText: string, query: string) => boolean
        default true

        `true` uses the built-in case-insensitive substring filter; `false` disables it (feed your own `items`); or pass a `(optionText, query) => boolean` predicate.

        id svelte
        string

        Stable id prefix; sub-element ids derive from it.

        inputValue svelte
        string

        Bindable query text (what the user typed — distinct from the selection).

        isLoading
        boolean
        default false

        Show the loading row instead of options (for async fetches).

        items required
        ListBoxNodeInput[]

        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 slot svelte
        Snippet

        Leading content inside the field (e.g. a search icon).

        loading slot svelte
        Snippet

        Content shown while `isLoading`.

        onCreate svelte
        (value: string) => void

        Fires when a free-text value is committed (`allowCreate`).

        onInputChange svelte
        (value: string) => void

        Fires when the query text changes (use with `filter={false}` for async).

        onOpenChange svelte
        (open: boolean) => void

        Fires when the open state changes.

        onValueChange svelte
        (value: ComboboxValue) => void

        Fires when the selection changes.

        open svelte
        boolean

        Bindable open state.

        passThrough
        ComboboxPassThrough

        Per-slot style / attribute override bag for customizing any rendered slot or composed child component.

        placeholder
        string

        Placeholder text for the input.

        placement
        "bottom""left""right""top""bottom-end""bottom-start""left-end""left-start""right-end""right-start""top-end""top-start"
        default "bottom-start"

        Floating-UI placement of the popup.

        selectionMode
        "single""multiple"
        default "single"

        `"single"` commits a label and closes; `"multiple"` toggles removable chips and keeps the popup open.

        size
        "sm""md""lg"
        default "md"

        Field and option size.

        value svelte
        ComboboxValue

        Bindable current selection. Single: a key or `null`; multiple: an array of keys.

        Plus all standard <input> HTML attributes.

        Type

        • Components
        • Blocks