component

Select

A select-only combobox: a non-editable trigger that shows the current selection (or a placeholder) and opens a ListBox in a Popover to choose from. It pairs the Popover positioning layer and the ListBox option engine behind one headless connectSelect core, so the same component supports single and multiple selection, type-to-jump, removable chips, a clear button, and native form submission. For a plain native <select> element, see Select (native).

Unlike the Combobox, the Select has no text field — you don’t type to filter, you type to jump to a matching option. The trigger is a focusable role="combobox" element (a <div>, so it can hold the chips’ remove buttons) and it keeps DOM focus while driving the list with aria-activedescendant (the ListBox’s focusStrategy="active-descendant" mode).

Default

Single selection. Click the trigger (or press ↓ / Enter / Space) to open, arrow keys to move the cursor (focus stays on the trigger), Enter to commit. Selecting an option updates the trigger label and closes the popup.

  • React

  • Svelte

  • Vue

  • Solid

  • Angular

  • Qwik

Selected: none

---
// GENERATED from packages/demos/select/default.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
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" },
];

const selected = null as string | null;
---

<div
  class={stack({ gap: "sm", alignItems: "flex-start" })}
  data-ir-root="select/default"
>
  <Select
    items={items}
    aria-label="Framework"
    placeholder="Pick a framework…"
    defaultValue={null as string | 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="select/default"]')
    .forEach((root) => hydrate([root]));
</script>

Multiple selection

Set selectionMode="multiple". Each chosen value renders as a removable chip (Group → Badge + a dismiss Button) inside the trigger; the popup stays open after each pick. Backspace removes the last chip.

  • React

  • Svelte

  • Vue

  • Solid

  • Angular

Selected: svelte

---
// GENERATED from packages/demos/select/multi-select.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
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" },
];

const selected = ["svelte"] as string[];
---

<div
  class={stack({ gap: "sm", alignItems: "flex-start" })}
  data-ir-root="select/multi-select"
>
  <Select
    items={items}
    selectionMode="multiple"
    chipOverflow={false}
    aria-label="Frameworks"
    placeholder="Pick frameworks…"
    defaultValue={["svelte"] as string[]}
    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="select/multi-select"]')
    .forEach((root) => hydrate([root]));
</script>

Chip overflow

When many values are selected the chip row can grow taller than the trigger 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.
  • chipOverflow="auto" — measure the trigger and fit as many chips as the width allows (best with a constrained width).
  • chipOverflow={false} — disable collapsing entirely: every chip renders inline.

The “+N” chip opens a popover listing the collapsed options with keyboard-operable remove buttons. chipMaxChars={n} and chipMaxWidth="8rem" trim long labels (the full label stays in the chip’s title).

  • React

  • Svelte

  • Vue

  • Solid

  • Angular

  • Qwik

Selected: react, svelte, vue

---
// GENERATED from packages/demos/select/chip-overflow.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
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" },
];

const selected = ["react", "svelte", "vue"] as string[];
---

<div
  class={stack({ gap: "sm", alignItems: "flex-start" })}
  data-ir-root="select/chip-overflow"
>
  <Select
    items={items}
    selectionMode="multiple"
    aria-label="Frameworks"
    placeholder="Pick frameworks…"
    defaultValue={["react", "svelte", "vue"] as string[]}
    data-ir="1"
  />
  <p>
    Selected: <!--ir:2-->
    {selected.join(", ") || "none"}
    <!--/ir-->
  </p>
</div>

<script>
  import { hydrate } from "./_client/chip-overflow.ts";

  document
    .querySelectorAll('[data-ir-root="select/chip-overflow"]')
    .forEach((root) => hydrate([root]));
</script>

Preselected & clearable

Seed an initial selection with defaultValue (a key for single-select, an array of keys for multiple). Add clearable to show a clear (×) button that resets the selection when something is selected.

defaultValue is the only way to set the selection in Astro. An Astro Select renders once and is uncontrolled, so it takes no value. Svelte (bind:value), React, Vue and Solid also take a controlled value with onValueChange.

  • React

  • Svelte

  • Vue

  • Solid

---
// GENERATED from packages/demos/select/preselected.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
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" },
];
---

<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
  <Select
    items={items}
    defaultValue="svelte"
    clearable
    aria-label="Framework"
    placeholder="Pick a framework…"
  />
</div>

Leading content

Pass a leading snippet (Svelte) or leading slot (Astro) to render an icon inside the trigger.

  • React

    A library for web and native UIs

  • Svelte

    Cybernetically enhanced web apps

  • Vue

    The progressive framework

  • Solid

    Simple and performant reactivity

---
// GENERATED from packages/demos/select/with-icons.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
import Stamp from "@pindoba/astro-stamp";
import { Layers } 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" })}>
  <Select items={items} aria-label="Framework" placeholder="Pick a framework…">
    <Stamp slot="leading" emphasis="ghost">
      <Layers />
    </Stamp>
  </Select>
</div>

Sections

Group related options with section nodes (type: "section").

  • Frontend
  • Backend
---
// GENERATED from packages/demos/select/grouped.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
import { stack } from "@pindoba/styled-system/patterns";
import type { ListBoxNodeInput } from "@pindoba/core-select";

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" })}>
  <Select
    items={items}
    aria-label="Technology"
    placeholder="Pick a technology…"
  />
</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

---
// GENERATED from packages/demos/select/disabled.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
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" })}>
  <Select
    items={items}
    disabledKeys={["vue", "angular"]}
    aria-label="Framework"
    placeholder="Pick a framework…"
  />
</div>

Sizes

Three sizes via the size prop: sm, md (default), and lg.

  • React

  • Svelte

  • Vue

  • React

  • Svelte

  • Vue

  • React

  • Svelte

  • Vue

---
// GENERATED from packages/demos/select/sizes.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { id: "react", label: "React" },
  { id: "svelte", label: "Svelte" },
  { id: "vue", label: "Vue" },
];
---

<div class={stack({ gap: "sm", alignItems: "flex-start" })}>
  <Select items={items} size="sm" aria-label="Small" placeholder="Small…" />
  <Select items={items} size="md" aria-label="Medium" placeholder="Medium…" />
  <Select items={items} size="lg" aria-label="Large" placeholder="Large…" />
</div>

Form submission

Set name and the selection posts inside a <form> via a hidden native <select> — so you also get browser autofill, native constraint validation (required), and write-back (an autofilled value updates the visible trigger). It submits exactly like a native select (one value, or repeated values for multiple). form associates the control with a <form> by id when it lives outside the form element. Very large option lists fall back to hidden <input>s.

  • React

  • Svelte

  • Vue

---
// GENERATED from packages/demos/select/form.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { id: "react", label: "React" },
  { id: "svelte", label: "Svelte" },
  { id: "vue", label: "Vue" },
];

const submitted = null as string | null;
---

<form
  class={stack({ gap: "sm", alignItems: "flex-start" })}
  data-ir-root="select/form"
  data-ir="1"
>
  <Select
    items={items}
    name="framework"
    defaultValue="svelte"
    aria-label="Framework"
    placeholder="Pick a framework…"
  />
  <button type="submit">Submit</button>
  <!--ir:2-->
  {submitted !== null && (
    <p>
      Submitted: <!--ir:3-->
      {submitted}
      <!--/ir-->
    </p>
  )}
  <!--/ir-->
</form>

<script>
  import { hydrate } from "./_client/form.ts";

  document
    .querySelectorAll('[data-ir-root="select/form"]')
    .forEach((root) => hydrate([root]));
</script>

Labeling

The trigger is a <div role="combobox"> (not a labelable element), so the consumer id lands on the hidden native <select> instead. That makes a plain <label for={id}> focus the Select — clicking the label focuses the trigger and paints the ring, without needing the Field wrapper. It focuses the trigger but does not open the dropdown, matching a native <select> in Chrome. Add aria-labelledby pointing at the same label so the role="combobox" trigger gets its screen-reader name (or use Field, which wires both for you).

  • React

  • Svelte

  • Vue

---
// GENERATED from packages/demos/select/labeled.demo.tsx — edit the source, not this file.
import Select from "@pindoba/astro-select";
import { stack } from "@pindoba/styled-system/patterns";

const items = [
  { id: "react", label: "React" },
  { id: "svelte", label: "Svelte" },
  { id: "vue", label: "Vue" },
];
---

<div class={stack({ gap: "2xs", alignItems: "flex-start" })}>
  <label for="astro-demo-select-labeled" id="astro-demo-select-labeled-label">
    Framework
  </label>
  <Select
    id="astro-demo-select-labeled"
    items={items}
    aria-labelledby="astro-demo-select-labeled-label"
    placeholder="Pick one…"
  />
</div>

The floating list is a ListBox whose container is a Panel root. Select 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.

<Select listboxRadius="lg" listboxBackground="surface.hill" items={items} />

Keyboard

Focus stays on the trigger throughout.

KeyAction
↓ / ↑Open the popup (when closed), then move the active option
Enter / SpaceOpen the popup, or select the active option
Home / EndJump to the first / last option
Type a letterOpen the popup and jump to a matching option (typeahead)
EscapeClose the popup (focus stays on the trigger)
BackspaceRemove the last chip (multiselect)
TabMove focus away and close the popup

Accessibility

  • The trigger is a role="combobox" with aria-haspopup="listbox", aria-expanded, and aria-controls (the listbox id).
  • The active option is surfaced via aria-activedescendant; DOM focus never leaves the trigger.
  • The popup is a role="listbox" of role="option" rows with aria-selected.
  • The clear button is labelled and removed from the tab order (tabindex="-1"); chip remove buttons are labelled Remove <value>.
  • Components
  • Blocks