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

---
import ListBox from "../listbox.astro";
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
    id="astro-demo-listbox-default"
    items={items}
    aria-label="Fruits"
    defaultSelectedKeys={["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
---
import ListBox from "../listbox.astro";
import Stamp from "@pindoba/astro-stamp";
import Badge from "@pindoba/astro-badge";
import { Apple, Banana, Cherry } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

// ListBox is agnostic about row content: it forwards whatever the consumer puts
// in a row's `<id>:leading` / `<id>:trailing` named slot into that row's
// `[data-affix]` wrapper (Control Var Contract — same model as Button). Here we
// hand it a leading <Stamp> (row-metric icon box) and a trailing <Badge>,
// mirroring the Svelte tab — the ListBox itself never imports Stamp/Badge. The
// check column is off by default (opt-in); the badge is pinned to the
// top-right of each row via the trailing-affix passThrough.
const 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",
  },
];
---

<div class={stack({ gap: "md", alignItems: "flex-start" })}>
  <ListBox
    id="astro-demo-listbox-banner-features"
    items={items}
    aria-label="Products"
    defaultSelectedKeys={["apple"]}
    passThrough={{
      leading: { props: { fit: "content" } },
      trailing: { style: { alignSelf: "flex-start" } },
    }}
  >
    <Stamp slot="apple:leading" size="xl"><Apple /></Stamp>
    <Badge slot="apple:trailing" size="sm" feedback="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" feedback="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
---
import ListBox from "../listbox.astro";
import Stamp from "@pindoba/astro-stamp";
import { Leaf, Sprout, LeafyGreen } from "@lucide/astro";
import { stack } from "@pindoba/styled-system/patterns";

// Rows with icons get a ghost Stamp through the `<id>:leading` named slot — it
// renders in the row's leading affix at the row metric, and the section title
// aligns to the icon edge automatically.
const 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" },
    ],
  },
];
---

<div class={stack({ gap: "md", alignItems: "flex-start" })}>
  <ListBox
    id="astro-demo-listbox-grouped"
    items={items}
    passThrough={{ container: { props: { style: "min-width: 28rem" } } }}
    aria-label="Produce"
    defaultSelectedKeys={["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. selectedKeys is a bindable Set of the chosen keys. This demo opts into the leading check column (showCheck) so the toggled state reads at a glance.

  • Red

  • Green

  • Blue

  • Yellow

---
import ListBox from "../listbox.astro";
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" },
];
---

<div class={stack({ gap: "md", alignItems: "flex-start" })}>
  <ListBox
    showCheck
    id="astro-demo-listbox-multi"
    items={items}
    aria-label="Colors"
    selectionMode="multiple"
    defaultSelectedKeys={["green", "blue"]}
  />
</div>

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

---
import ListBox from "../listbox.astro";
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
    id="astro-demo-listbox-disabled"
    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 results found
---
import ListBox from "../listbox.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", alignItems: "flex-start" })}>
  <ListBox id="astro-demo-listbox-empty" items={[]} aria-label="Results" />
</div>
  • Loading…
---
import ListBox from "../listbox.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", alignItems: "flex-start" })}>
  <ListBox
    id="astro-demo-listbox-loading"
    items={[]}
    aria-label="Results"
    isLoading
  />
</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 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

  • Find

    Search the current file

  • Settings

    Open preferences

---
import ListBox from "../listbox.astro";
import { stack } from "@pindoba/styled-system/patterns";
import { css } from "@pindoba/styled-system/css";

// Active-descendant (command-palette) pattern: DOM focus stays on the <input>.
// The <ListBox> self-boots and stashes its handle on the root as `.listbox`;
// the input forwards nav keys to that handle and mirrors the announced
// `aria-activedescendant`, while the list only renders / scrolls its cursor.
const items = [
  { 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: "find", label: "Find", description: "Search the current file" },
  { id: "settings", label: "Settings", description: "Open preferences" },
];

const inputClass = 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)" },
});
---

<div class={stack({ gap: "sm", alignItems: "stretch", maxWidth: "20rem" })}>
  <input
    type="text"
    id="astro-cp-input"
    role="combobox"
    aria-expanded="true"
    aria-controls="astro-cp-listbox"
    aria-autocomplete="list"
    placeholder="Type a command…"
    class={inputClass}
  />
  <ListBox
    id="astro-cp-listbox"
    items={items}
    focusStrategy="active-descendant"
    selectionMode="none"
    aria-label="Commands"
    passThrough={{ root: { style: { maxHeight: "12rem", overflowY: "auto" } } }}
  />
</div>

<script>
  import type { ListBoxRootElement } from "../boot-listbox";

  function wire() {
    const input = document.getElementById(
      "astro-cp-input",
    ) as HTMLInputElement | null;
    const root = document.getElementById(
      "astro-cp-listbox",
    ) as ListBoxRootElement | null;
    if (!input || !root) return;

    const NAV = ["ArrowDown", "ArrowUp", "Home", "End", "Enter"];
    input.addEventListener("keydown", (event) => {
      // Lazy lookup: the listbox self-boots independently, so the handle may
      // attach after this listener registers.
      if (NAV.includes(event.key)) root.listbox?.handleKeydown(event);
    });
    root.addEventListener("listbox:activedescendantchange", (event) => {
      const id = (event as CustomEvent<{ id: string | null }>).detail.id;
      if (id) input.setAttribute("aria-activedescendant", id);
      else input.removeAttribute("aria-activedescendant");
    });
  }

  wire();
  document.addEventListener("astro:page-load", wire);
</script>

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 or aria-labelledby. Options are role="option" and expose aria-selected (omitted entirely in none mode) and aria-disabled. Sections are role="group" labelled by their title.

props · 11 shown · 11 total
feedback
"neutral""primary""success""warning""danger"
default "neutral"

Semantic surface color (surfaces / border / text).

focusStrategy
"roving""active-descendant"
default "roving"

Keyboard-cursor model. `"roving"` (the default) lets the list own DOM focus — the focused option becomes the tab stop and real focus moves onto it. `"active-descendant"` keeps DOM focus on an external control (e.g. a command-palette / combobox `<input>`): options omit `tabindex`, the cursor is surfaced via `data-focused` + the focused option's `id` (for the control's `aria-activedescendant`), and navigation is driven from outside.

isLoading
boolean
default false

Render the loading row instead of the options.

items required
ListBoxNodeInput[]

The list's data, in render order. An option is `{ id, label, description?, eyebrow?, leading?, trailing?, textValue?, disabled? }` (label/description/ eyebrow map onto the row's Banner; leading/trailing render in the row's affix wrappers, Button-style); a section is `{ id, type: "section", title?, header?, items }`; a divider is `{ id, type: "separator" }`; a non-interactive content block is `{ id, type: "custom", content? }`.

passThrough
ListBoxPassThrough

Per-slot escape hatch to inject Panda styles or HTML attributes into any slot of the component.

pattern
"listbox""menu"
default "listbox"

The ARIA pattern the list renders as. `"listbox"` (default) is a value-selection list; `"menu"` renders `role="menu"` + `menuitem` semantics for action menus (consumed by the Menu component).

revealStrategy
"immediate""deferred"
default "immediate"

When the keyboard cursor first appears (active-descendant mode): - `"immediate"` (default): the first `ArrowDown`/`ArrowUp` lands on (and shows) the first/last option right away. Right for a standalone list or a command palette where the popup only exists once the user is navigating. - `"deferred"`: the first arrow *reveals* a pre-seated cursor (on the selected option, else the first/last by direction) **without stepping**; only subsequent arrows step. This is the ARIA combobox behavior — the cursor + `aria-activedescendant` stay hidden until the user navigates.

rootAttrs
RootAttributes

Extra HTML attributes spread onto the root `listbox` element.

selectionMode
"none""single""multiple"
default "single"

How clicks / Enter / Space resolve into a selection. `"none"` is a command-palette style list that activates but never persists a selected state.

showCheck
boolean
default false

Opt in to the selection check column — a reserved-width leading indicator (mirroring the menu pattern's checked glyph) revealed on selected rows. Off by default: selection reads through `aria-selected` and the selected surface treatment alone. Value-selection controls (Select, Combobox) opt in. No effect when `selectionMode` is `"none"`, where there is nothing to check.

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

Visual size variant — controls option row height/padding (and the empty/loading status text size). Option text itself keeps one size across variants (the rows render an `xs` Banner).

Plus all standard <ul> HTML attributes.

Type

  • Components
  • Blocks