component

ListBox

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.

Default

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

  • Apple

  • Banana

  • Cherry

  • Date

  • Elderberry

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

Rich rows (Banner + affixes)

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

  • In season

    Apple

    Crisp and sweet

    New
  • Staple

    Banana

    Rich in potassium

  • Limited

    Cherry

    Tart stone fruit

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

Sections

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.

  • Fruits
  • Vegetables
  • Herbs
---
// 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>

Multiple selection

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.

  • Red

  • Green

  • Blue

  • Yellow

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>

Disabled options

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

  • Draft

  • In review

  • Published

  • Archived

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

Empty & loading states

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.

  • No matching results
---
// 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>
  • Fetching results…
---
// 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>

Command palette (external focus)

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.

  • New File

    Create a blank file

  • Open…

    Open a file or folder

  • Save

    Write the active file

  • Save All

    Write every file

  • Find

    Search the current file

  • Replace

    Find and replace

  • Settings

    Open preferences

  • Toggle Theme

    Light / dark

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

Card

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.
  • The list’s surface props (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.

Build region

Where CI jobs for this project run

  • US East — Ashburn

    iad1 · 12 ms median queue

    Default
  • EU Central — Frankfurt

    fra1 · 18 ms median queue

  • South America — São Paulo

    gru1 · 31 ms median queue

    Beta
  • Oceania — Sydney

    syd1 · no free runners

    At capacity
Changes apply to new pipelines only
---
// 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 pass card: false, so their DOM and selectors stay the same.

Surface

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.

  • Apple

  • Banana

  • Cherry

  • Date

Default radius="md".

  • Apple

  • Banana

  • Cherry

  • Date

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.

Shortcuts

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" } } }}
/>

Keyboard

KeyAction
ArrowDown / ArrowUpMove the cursor to the next / previous option
Home / EndMove to the first / last option
EnterActivate (select) the focused option
SpaceToggle selection of the focused option
typingJump to the first option matching the typed text

Accessibility

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.

  • Components
  • Blocks