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).
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.
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>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.
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>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).
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>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.
---
// 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>Pass a leading snippet (Svelte) or leading slot (Astro) to render an icon inside the trigger.
---
// 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>Group related options with section nodes (type: "section").
---
// 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>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/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>Three sizes via the size prop: sm, md (default), and lg.
---
// 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>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.
---
// 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>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).
---
// 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} />
Focus stays on the trigger throughout.
| Key | Action |
|---|---|
↓ / ↑ | Open the popup (when closed), then move the active option |
Enter / Space | Open the popup, or select the active option |
Home / End | Jump to the first / last option |
| Type a letter | Open the popup and jump to a matching option (typeahead) |
Escape | Close the popup (focus stays on the trigger) |
Backspace | Remove the last chip (multiselect) |
Tab | Move focus away and close the popup |
role="combobox" with aria-haspopup="listbox", aria-expanded, and aria-controls (the listbox id).aria-activedescendant; DOM focus never leaves the trigger.role="listbox" of role="option" rows with aria-selected.tabindex="-1"); chip remove buttons are labelled Remove <value>.