Skip to the content

ComponentsForm

Listbox

A list of options always in view, from which people choose one or several, with an optional filter field.

Import

import { Listbox, ListboxEmpty, ListboxGroup, ListboxItem, ListboxLabel, ListboxSeparator } from "@booleanpress/ui/listbox"

Usage

Listbox follows the APG listbox pattern on plain elements: the list is one tab stop, the arrow keys move a highlight that is the list's aria-activedescendant, and Space or Enter chooses. It needs no peer package.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

export function RegionList() {
  return (
    <>
      <Label id="region-label">Sending region</Label>
      <Listbox aria-labelledby="region-label" defaultValue="eu-west-1">
        <ListboxItem value="eu-west-1">Europe (Ireland)</ListboxItem>
        <ListboxItem value="us-east-1">US East (N. Virginia)</ListboxItem>
      </Listbox>
    </>
  )
}

It is uncontrolled with defaultValue, or controlled with value and onValueChange: a string (or null) for one choice, an array of strings with multiple. Name it with aria-labelledby pointing at a visible label, or aria-label. Chosen options fill with --highlight; indicator="check" adds a check at their start and indicator="checkbox" draws a checkbox on every option. filter adds a field above the list that narrows the options as people type; ListboxEmpty, a direct child of Listbox, then says "No results" in a polite live region after the list. An option's textValue is what the filter and type-ahead match when its children are not plain text. Group options with ListboxGroup and ListboxLabel. With name, each chosen value is submitted as a hidden input of that name, none while the list is disabled; a form reset brings back defaultValue. The list is as wide as its container and as tall as its options; listClassName="max-h-64" makes it scroll. For a choice that opens from a field, use Select, MultiSelect or Combobox.

Examples

Basic

One choice from a list always in view; the chosen option fills with --highlight.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)", "US West (Oregon)", "Asia Pacific (Mumbai)"]

export default function ListboxBasic() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-region-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-region-label" defaultValue="Europe (Ireland)">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

Multiple

multiple: Space, Enter or a click toggles an option; the value is an array.

import { useState } from "react"
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const EVENTS = ["Delivered", "Bounced", "Complained", "Opened", "Clicked"]

export default function ListboxMultiple() {
  const [events, setEvents] = useState(["Bounced", "Complained"])

  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-events-label">Webhook events</Label>
      <Listbox aria-labelledby="listbox-events-label" multiple value={events} onValueChange={setEvents}>
        {EVENTS.map((event) => (
          <ListboxItem key={event} value={event}>
            {event}
          </ListboxItem>
        ))}
      </Listbox>
      <p className="text-xs text-muted-foreground">{events.length ? `Sending ${events.join(", ").toLowerCase()}.` : "Sending nothing."}</p>
    </div>
  )
}

Checkbox selection

indicator="checkbox" draws a checkbox on every option, ticked once chosen.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const SITES = ["shop.example.com", "blog.example.com", "docs.example.com", "status.example.com"]

export default function ListboxCheckbox() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-sites-label">Send from these sites</Label>
      <Listbox aria-labelledby="listbox-sites-label" multiple indicator="checkbox" defaultValue={["shop.example.com", "docs.example.com"]}>
        {SITES.map((site) => (
          <ListboxItem key={site} value={site}>
            {site}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

Groups

ListboxGroup and ListboxLabel name sets of options; listClassName="max-h-64" makes a long list scroll.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxGroup, ListboxItem, ListboxLabel } from "@booleanpress/ui/listbox"

const GROUPS = [
  { label: "API mailers", items: ["Amazon SES", "Mailgun", "Postmark"] },
  { label: "SMTP", items: ["Gmail SMTP", "Outlook SMTP", "Custom SMTP"] },
]

export default function ListboxGroups() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-groups-label">Mailer</Label>
      <Listbox aria-labelledby="listbox-groups-label" defaultValue="Postmark" listClassName="max-h-64">
        {GROUPS.map((group) => (
          <ListboxGroup key={group.label}>
            <ListboxLabel>{group.label}</ListboxLabel>
            {group.items.map((mailer) => (
              <ListboxItem key={mailer} value={mailer}>
                {mailer}
              </ListboxItem>
            ))}
          </ListboxGroup>
        ))}
      </Listbox>
    </div>
  )
}

Filter

filter adds a field that narrows the options as you type; the arrows and Enter work from it.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxEmpty, ListboxItem } from "@booleanpress/ui/listbox"

const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "Europe/Madrid", "America/New_York", "America/Chicago", "Asia/Dhaka", "Asia/Tokyo"]

export default function ListboxFilter() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-zone-label">Report time zone</Label>
      <Listbox aria-labelledby="listbox-zone-label" filter filterPlaceholder="Search time zones" defaultValue="UTC" listClassName="max-h-48">
        {ZONES.map((zone) => (
          <ListboxItem key={zone} value={zone}>
            {zone}
          </ListboxItem>
        ))}
        <ListboxEmpty />
      </Listbox>
    </div>
  )
}

Custom option

icon and description give an option an avatar and a second line; textValue is what type-ahead matches.

import { Avatar, AvatarFallback } from "@booleanpress/ui/avatar"
import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const AGENTS = [
  { id: "sc", name: "Sara Chowdhury", role: "Support lead" },
  { id: "ar", name: "Arif Rahman", role: "Billing specialist" },
  { id: "jk", name: "Jonas Keller", role: "Deliverability engineer" },
]

export default function ListboxCustomOption() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label id="listbox-agent-label">Assign the ticket to</Label>
      <Listbox aria-labelledby="listbox-agent-label" defaultValue="ar">
        {AGENTS.map((agent) => (
          <ListboxItem
            key={agent.id}
            value={agent.id}
            textValue={agent.name}
            description={agent.role}
            icon={
              <Avatar>
                <AvatarFallback className="text-xs">{agent.id.toUpperCase()}</AvatarFallback>
              </Avatar>
            }
          >
            <span className="font-medium">{agent.name}</span>
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

Disabled options

A disabled option stays in view at 60%, and the highlight skips it.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const PLANS = [
  { value: "free", label: "Free", disabled: false },
  { value: "starter", label: "Starter", disabled: false },
  { value: "agency", label: "Agency (sold out)", disabled: true },
  { value: "enterprise", label: "Enterprise (contact sales)", disabled: true },
]

export default function ListboxDisabledOptions() {
  return (
    <div className="flex w-full max-w-60 flex-col gap-2">
      <Label id="listbox-plan-label">Plan</Label>
      <Listbox aria-labelledby="listbox-plan-label" defaultValue="starter">
        {PLANS.map((plan) => (
          <ListboxItem key={plan.value} value={plan.value} disabled={plan.disabled}>
            {plan.label}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

Disabled

disabled greys the list, keeps its value and takes it out of the tab order.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)"]

export default function ListboxDisabled() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-disabled-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-disabled-label" disabled defaultValue="Europe (Frankfurt)">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
    </div>
  )
}

Invalid

aria-invalid draws the error edge; aria-describedby reads the message with the name.

import { Label } from "@booleanpress/ui/label"
import { Listbox, ListboxItem } from "@booleanpress/ui/listbox"

const REGIONS = ["Europe (Ireland)", "Europe (Frankfurt)", "US East (N. Virginia)"]

export default function ListboxInvalid() {
  return (
    <div className="flex w-full max-w-56 flex-col gap-2">
      <Label id="listbox-invalid-label">Sending region</Label>
      <Listbox aria-labelledby="listbox-invalid-label" aria-invalid aria-describedby="listbox-invalid-error">
        {REGIONS.map((region) => (
          <ListboxItem key={region} value={region}>
            {region}
          </ListboxItem>
        ))}
      </Listbox>
      <p id="listbox-invalid-error" className="text-xs text-destructive-strong">
        Choose the region your mailer sends from.
      </p>
    </div>
  )
}

Accessibility

Semantics
The list has role="listbox" (with aria-multiselectable="true" for multiple) and holds role="option" items, each with aria-selected and, when disabled, aria-disabled; groups are role="group" named by their label. With filter, the field is a role="combobox" with aria-expanded="true" that controls the list and carries its aria-activedescendant. The empty message is a role="status" after the list, so the listbox holds only options and groups and the message is announced.
Labels
Name the list with aria-labelledby or aria-label. The filter field is named "Filter options" from the provider.
Focus
The list (or, with filter, its field) is one tab stop. Focus highlights the chosen option, else the first; the highlight is shown only while the list has focus. A click chooses and leaves focus on the list.
Known limits
  • There is no range selection with Shift and no Ctrl+A; each option is toggled on its own.
  • Options are always in the page: thousands of them belong in a virtualised Combobox.

Keyboard

Keyboard
KeyBehaviour
โ†“orโ†‘Move the highlight to the next or the previous option, stopping at the ends.
HomeorEndOn the list, move the highlight to the first or the last option.
SpaceOn the list, chooses the highlighted option; with multiple, toggles it.
EnterChooses the highlighted option; with multiple, toggles it. Works from the filter field too.
Aโ€“ZOn the list, type-ahead: moves to the next option whose text starts with the letters typed. In the filter field, narrows the options.

API

Listbox

Renders a div and passes it every other prop.

Listbox props
PropTypeDefaultDescription
defaultValuestring | string[] | nullThe value it starts with, when it controls itself.
disabledbooleanfalseMakes the list read-only and greys it; it leaves the tab order.
filterbooleanfalseShows a field above the list that filters the options as people type.
filterPlaceholderstringThe filter field's placeholder.
formstringThe id of the form the values belong to, for a list placed outside it.
indicator"none" | "checkbox" | "check"nonenone marks chosen options with the fill only; check adds a check at the start; checkbox draws a checkbox.
listClassNamestringClasses for the scrolling list inside the frame, such as a maximum height.
multiplebooleanfalseLets people choose several options; Space and Enter toggle the highlighted one.
namestringSubmits each chosen value as a hidden input of this name, as a native select does.
onValueChange((value: string | null) => void) | ((value: string[]) => void)Called with the new value when an option is chosen.
valuestring | string[] | nullThe chosen value, when you control it; null for none. Pair it with onValueChange.

ListboxEmpty

Renders a div and passes it every other prop.

ListboxGroup

Renders a div and passes it every other prop.

ListboxItem

Renders a div and passes it every other prop.

ListboxItem props
PropTypeDefaultDescription
valuerequiredstringThe value this option chooses.
descriptionReactNodeA second, muted line under the label.
disabledbooleanfalseThe option cannot be highlighted or chosen.
iconReactNodeLeading media beside both lines, such as an avatar or a flag.
textValuestringThe text matched by the filter and type-ahead, when the children are not plain text.

ListboxLabel

Renders a div and passes it every other prop.

ListboxSeparator

Renders a div and passes it every other prop.

Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.

Data attributes: data-slot="listbox" (Listbox), data-slot="listbox-empty" (ListboxEmpty), data-slot="listbox-group" (ListboxGroup), data-slot="listbox-item" (ListboxItem), data-slot="listbox-label" (ListboxLabel), data-slot="listbox-separator" (ListboxSeparator), and data-disabled, data-value, data-text, data-selected, data-highlighted, data-indicator.

Provider strings: filterOptions, noResults (BooleanUIProvider's strings).

Theming

The frame takes the field tokens: --field, --control for the edge, --ring while it has focus, --invalid when invalid and --field-disabled when disabled. Options use --accent while highlighted and --highlight once chosen (--highlight-focus both); a drawn checkbox is --primary once chosen.

The tokens its classes read. Change their values in your theme, and it follows.

Theme tokens
TokenUsed for
--accentbackground
--accent-foregroundtext
--borderbackground
--controlborder, background
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--foregroundtext
--highlightbackground
--highlight-focusbackground
--highlight-foregroundtext, border
--invalidborder
--muted-foregroundtext
--primaryborder, background
--primary-foregroundtext
--ringborder