A list of selectable options with full keyboard support. ListBox is a foundational building block — it manages selection and a roving-tabindex focus cursor, and renders flat lists or grouped sections. It is built with inversion of control in mind: the headless connectListBox core is a pure prop builder driven entirely by selection / focus inputs, while a separate pure reducer owns the keyboard and selection logic. That seam is what a future Select (dropdown) or Command (combobox) plugs into.
Data is described declaratively through the items prop — flat options, sections, or a mix — rather than as markup. The core flattens it into a normalized collection before anything renders, so navigation, typeahead, and counts never touch the DOM.
An option’s text stack renders through a Banner — rows can carry an eyebrow, a label (heading) and a description (subheading) — while leading / trailing content renders in the row’s affix wrappers, the same Control Var Contract model as Button, so rows share Button’s sizing and icon metrics. The selection check is an opt-in (showCheck) leading column, mirroring the menu pattern’s checked indicator; it is always rendered when enabled, so labels stay aligned and the list keeps a constant width as the selection changes.
Single selection is the default. Click an option or focus the list and use the arrow keys, then Enter or Space to select. Type to jump to an option by its text (typeahead).
---
// GENERATED from packages/demos/listbox/default.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "apple", label: "Apple" },
{ id: "banana", label: "Banana" },
{ id: "cherry", label: "Cherry" },
{ id: "date", label: "Date" },
{ id: "elderberry", label: "Elderberry" },
];
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox items={items} aria-label="Fruits" defaultValue={["banana"]} />
</div>Options can carry an eyebrow, a leading element, and a trailing element alongside the heading and description. The text stack renders through the row’s Banner; leading / trailing render in the row’s [data-affix] wrappers, exactly like a Button’s affixes. ListBox stays agnostic about what those elements are — it never imports Stamp or Badge; it just forwards whatever you give it. In Svelte you pass them as snippets on the item (leading / trailing). In Astro, where components can’t ride in a data array, you attach them through per-row named slots keyed by the option id (<id>:leading / <id>:trailing). Here each row gets a Stamp wrapping a lucide icon and a Badge pinned to the top-right; selection reads through the row surface alone (the check column is opt-in via showCheck).
---
// GENERATED from packages/demos/listbox/banner-features.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import Badge from "@pindoba/astro-badge";
import Stamp from "@pindoba/astro-stamp";
import { Apple, Banana, Cherry } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox
aria-label="Products"
defaultValue={["apple"]}
passThrough={{
leading: { props: { fit: "content" } },
trailing: { style: { alignSelf: "flex-start" } },
}}
items={[
{
id: "apple",
eyebrow: "In season",
label: "Apple",
description: "Crisp and sweet",
},
{
id: "banana",
eyebrow: "Staple",
label: "Banana",
description: "Rich in potassium",
},
{
id: "cherry",
eyebrow: "Limited",
label: "Cherry",
description: "Tart stone fruit",
},
]}
>
<Stamp slot="apple:leading" size="xl">
<Apple />
</Stamp>
<Badge slot="apple:trailing" size="sm" tone="success">
New
</Badge>
<Stamp slot="banana:leading" size="xl">
<Banana />
</Stamp>
<Stamp slot="cherry:leading" size="xl">
<Cherry />
</Stamp>
<Badge slot="cherry:trailing" size="sm" tone="warning">
Sale
</Badge>
</ListBox>
</div>Group related options by passing section nodes (type: "section") in items. Each section renders a role="group" labelled by its title; its options stay part of the same keyboard sequence. Rows can carry leading icons — a ghost Stamp through leading renders in the row’s affix at the row metric, as in the Herbs section below.
---
// GENERATED from packages/demos/listbox/grouped.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import Stamp from "@pindoba/astro-stamp";
import { Leaf, Sprout, LeafyGreen } from "@lucide/astro";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox
items={[
{
id: "fruits",
type: "section" as const,
title: "Fruits",
items: [
{ id: "apple", label: "Apple" },
{ id: "banana", label: "Banana" },
],
},
{
id: "vegetables",
type: "section" as const,
title: "Vegetables",
items: [
{ id: "carrot", label: "Carrot" },
{ id: "kale", label: "Kale" },
{ id: "potato", label: "Potato" },
],
},
{
id: "herbs",
type: "section" as const,
title: "Herbs",
items: [
{ id: "basil", label: "Basil" },
{ id: "mint", label: "Mint" },
{ id: "sage", label: "Sage" },
],
},
]}
passThrough={{ container: { style: css.raw({ minWidth: "28rem" }) } }}
aria-label="Produce"
defaultValue={["kale"]}
>
<Stamp slot="basil:leading" emphasis="ghost">
<Leaf />
</Stamp>
<Stamp slot="mint:leading" emphasis="ghost">
<Sprout />
</Stamp>
<Stamp slot="sage:leading" emphasis="ghost">
<LeafyGreen />
</Stamp>
</ListBox>
</div>Set selectionMode="multiple" to let Space (or a click) toggle individual options. value is a bindable Set of the chosen keys. This demo opts into the check column (showCheck) so the toggled state reads at a glance.
The check sits at the trailing edge of the row by default (checkPlacement="end"), so labels keep the panel edge and unchosen rows don’t reserve an empty leading column — the same default as the menu pattern. Set checkPlacement="start" to move it before the label.
Selected: green, blue
---
// GENERATED from packages/demos/listbox/multi-select.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "red", label: "Red" },
{ id: "green", label: "Green" },
{ id: "blue", label: "Blue" },
{ id: "yellow", label: "Yellow" },
];
const selected = new Set(["green", "blue"]);
---
<div
class={stack({ gap: "sm", alignItems: "flex-start" })}
data-ir-root="listbox/multi-select"
>
<ListBox
showCheck
items={items}
aria-label="Colors"
selectionMode="multiple"
value={new Set(["green", "blue"])}
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="listbox/multi-select"]')
.forEach((root) => hydrate([root]));
</script>Mark an option disabled, or list its key in disabledKeys. Disabled options are skipped by arrow navigation and typeahead and cannot be selected.
---
// GENERATED from packages/demos/listbox/disabled.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
const items = [
{ id: "draft", label: "Draft" },
{ id: "review", label: "In review", disabled: true },
{ id: "published", label: "Published" },
{ id: "archived", label: "Archived" },
];
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox items={items} aria-label="Status" disabledKeys={["archived"]} />
</div>When items is empty the list renders an empty state (customizable via the empty snippet in Svelte). While isLoading is true it renders a role="status" loading row instead.
---
// GENERATED from packages/demos/listbox/empty.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox items={[]} aria-label="Results">
<Fragment slot="empty">No matching results</Fragment>
</ListBox>
</div>---
// GENERATED from packages/demos/listbox/loading.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", alignItems: "flex-start" })}>
<ListBox items={[]} aria-label="Results" isLoading>
<Fragment slot="loading">Fetching results…</Fragment>
</ListBox>
</div>By default the list owns DOM focus (focusStrategy="roving"). Set focusStrategy="active-descendant" to keep focus on an external control — a search <input> — while the list renders the keyboard cursor. This is the command-palette / custom-combobox pattern: options drop out of the tab order, the cursor is surfaced via data-focused plus the focused option’s id (for the input’s aria-activedescendant), and the row scrolls into view instead of stealing focus.
The input drives the list without reimplementing navigation. In Svelte, grab the component with bind:this and forward keystrokes to its handleKeydown (or the granular navigate / activateFocused / typeahead methods), and bind activeDescendantId onto the input. In Solid, the ref prop receives the same handle (ref={(h) => (listbox = h)}); forward the input’s keydown to listbox.handleKeydown(e) and mirror onActiveDescendantChange onto the input. In Astro, the list self-boots and stashes the same handle on its root <ul> as .listbox; forward the input’s keydown to root.listbox.handleKeydown(e) and listen for the listbox:activedescendantchange event to mirror aria-activedescendant.
---
// GENERATED from packages/demos/listbox/command-palette.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";
const commands = [
{ id: "new-file", label: "New File", description: "Create a blank file" },
{ id: "open", label: "Open…", description: "Open a file or folder" },
{ id: "save", label: "Save", description: "Write the active file" },
{ id: "save-all", label: "Save All", description: "Write every file" },
{ id: "find", label: "Find", description: "Search the current file" },
{ id: "replace", label: "Replace", description: "Find and replace" },
{ id: "settings", label: "Settings", description: "Open preferences" },
{ id: "toggle-theme", label: "Toggle Theme", description: "Light / dark" },
];
const listboxId = "command-palette-list";
const query = "";
const activeDescendantId = undefined as string | undefined;
const lastRun = null as string | null;
const items = commands.filter((c) =>
c.label.toLowerCase().includes(query.trim().toLowerCase()),
);
---
<div
class={stack({ gap: "sm", alignItems: "stretch", maxWidth: "20rem" })}
data-ir-root="listbox/command-palette"
>
<input
type="text"
role="combobox"
aria-expanded="true"
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeDescendantId}
placeholder="Type a command…"
class={css({
px: "sm",
py: "xs",
borderRadius: "sm",
border: "1px solid token(colors.gray.300)",
bg: "gray.50",
_focusVisible: { outline: "2px solid token(colors.blue.500)" },
})}
data-ir="1"
/>
<ListBox
id={listboxId}
items={items}
focusStrategy="active-descendant"
selectionMode="none"
aria-label="Commands"
passThrough={{
root: { style: css.raw({ maxHeight: "12rem", overflowY: "auto" }) },
}}
data-ir="2"
/>
<!--ir:3-->
{lastRun && (
<p class={css({ fontSize: "sm", color: "gray.600" })}>
Ran command:{" "}
<strong>
<!--ir:4-->
{lastRun}
<!--/ir-->
</strong>
</p>
)}
<!--/ir-->
</div>
<script>
import { hydrate } from "./_client/command-palette.ts";
document
.querySelectorAll('[data-ir-root="listbox/command-palette"]')
.forEach((root) => hydrate([root]));
</script>A standalone ListBox is card-based: the list renders inside its own Card, so a heading, header actions and a footer come through the card prop. The <ul> is the card’s edge-to-edge content and keeps its own row padding, so the card header lines up with the options.
card.heading names the list — the <ul role="listbox"> (or role="menu") is aria-labelledby the rendered heading unless you pass aria-label / aria-labelledby.background, border, radius, shadow…) become the card’s defaults, so a ListBox with no card props looks like the bare list did. In card mode the inner container turns transparent and the Card carries the surface.background plus contentBackground gives the inset look: the list on its own panel inside a shell.---
// GENERATED from packages/demos/listbox/card.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import Badge from "@pindoba/astro-badge";
import Button from "@pindoba/astro-button";
---
<ListBox
defaultValue={["iad"]}
showCheck
card={{
heading: "Build region",
subheading: "Where CI jobs for this project run",
background: "surface.base",
contentBackground: "surface.peak",
footer: { leading: "Changes apply to new pipelines only" },
}}
items={[
{
id: "iad",
label: "US East — Ashburn",
description: "iad1 · 12 ms median queue",
},
{
id: "fra",
label: "EU Central — Frankfurt",
description: "fra1 · 18 ms median queue",
},
{
id: "gru",
label: "South America — São Paulo",
description: "gru1 · 31 ms median queue",
},
{
id: "syd",
label: "Oceania — Sydney",
description: "syd1 · no free runners",
disabled: true,
},
]}
>
<Button slot="card-trailing" size="sm" emphasis="secondary">
Add region
</Button>
<Badge slot="iad:trailing" size="sm" tone="primary">
Default
</Badge>
<Badge slot="gru:trailing" size="sm" tone="warning">
Beta
</Badge>
<Badge slot="syd:trailing" size="sm">
At capacity
</Badge>
</ListBox>passThrough.card styles the Card root (style) or adds attributes to it (props); deeper Card slots go through card.passThrough. card={false} renders the bare list with no Card:
<ListBox {items} aria-label="Regions" card={false} />
Migrating from the pre-card ListBox: a standalone list is now wrapped in a Card root — select it with
[data-component="card"][data-card-kind="listbox"]. The popup lists of Select, Combobox, Menu and Command Palette are unchanged: those composites passcard: false, so their DOM and selectors stay the same.
The list container is a Panel root (with card={false}; in card mode the Card carries these props), so it takes every Panel surface prop — background, emphasis, tone, padding, radius (plus the per-side radiusTop / radiusBottom / radiusLeft / radiusRight), border, shadow and translucent. The option rows track the container’s corner automatically, so changing radius reshapes the whole list.
radius="inner" is the concentric corner: it resolves against the nearest Panel ancestor’s radius minus its padding. Drop a list inside a Card and the two curves stay in step no matter what the Card’s radius and size are.
The list surface is a Panel root, so radius="inner" resolves the concentric corner against the Card it sits in — the list tracks the Card's radius minus its content padding, and the option rows follow.
Default radius="md".
radius="inner".
---
// GENERATED from packages/demos/listbox/inner-radius.demo.tsx — edit the source, not this file.
import ListBox from "@pindoba/astro-listbox";
import Card from "@pindoba/astro-card";
import { css } from "@pindoba/styled-system/css";
import { stack, grid } from "@pindoba/styled-system/patterns";
const items = [
{ id: "apple", label: "Apple" },
{ id: "banana", label: "Banana" },
{ id: "cherry", label: "Cherry" },
{ id: "date", label: "Date" },
];
---
<div class={stack({ gap: "md", direction: "column", width: "full" })}>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
The list surface is a Panel root, so <code>radius="inner"</code> resolves
the concentric corner against the Card it sits in — the list tracks the
Card's <code>radius</code> minus its content padding, and the option rows
follow.
</p>
<div class={grid({ columns: 2, gap: "lg" })}>
<Card size="md" radius="2xl" border="default">
<ListBox
items={items}
aria-label="Fruits, default radius"
defaultValue={["banana"]}
/>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
Default <code>radius="md"</code>.
</p>
</Card>
<Card size="md" radius="2xl" border="default">
<ListBox
items={items}
radius="inner"
aria-label="Fruits, inner radius"
defaultValue={["banana"]}
/>
<p class={css({ fontSize: "sm", color: "panel.text.muted" })}>
<code>radius="inner"</code>.
</p>
</Card>
</div>
</div>Inside a Popover — the way Select, Combobox and Menu mount it — the list is nested under a transparent, unpadded <dialog>, so inner would resolve against that instead. Those components expose listboxRadius (alongside listboxShadow, listboxBorder and listboxBackground) so you can set the popup list’s surface explicitly.
An option’s shortcut ("mod+S", "⇧⌘P", ["Ctrl", "K"], a sequence like "G I") renders through KbdGroup on the row’s trailing edge, sized to the row through the Control Var Contract. The row gets a matching aria-keyshortcuts ("Meta+S"), and the visible group is decorative so screen readers don’t read the shortcut twice.
Three list-level options tune every row’s group at once:
shortcutAppearance — "soft" (default: quiet square keys on a subtle fill, no border or frame), "framed" (one frame with a cap per key), "separate" or "plain".shortcutPlatform — "auto" (default: Mac labels on the first render, then the viewer’s platform), "mac" or "other".passThrough.shortcut.props — any other KbdGroup prop for every row, e.g. { separator: "plus" }.<ListBox
options={[{ id: "save", heading: "Save", shortcut: "mod+S" }]}
shortcutAppearance="plain"
passThrough={{ shortcut: { props: { separator: "plus" } } }}
/>
| Key | Action |
|---|---|
ArrowDown / ArrowUp | Move the cursor to the next / previous option |
Home / End | Move to the first / last option |
Enter | Activate (select) the focused option |
Space | Toggle selection of the focused option |
| typing | Jump to the first option matching the typed text |
The root is role="listbox" (with aria-multiselectable in multiple mode) and must be given an accessible name via aria-label, aria-labelledby or a card.heading (which labels it automatically). Options are role="option" and expose aria-selected (omitted entirely in none mode) and aria-disabled. Sections are role="group" labelled by their title.