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"(witharia-multiselectable="true"formultiple) and holdsrole="option"items, each witharia-selectedand, when disabled,aria-disabled; groups arerole="group"named by their label. Withfilter, the field is arole="combobox"witharia-expanded="true"that controls the list and carries itsaria-activedescendant. The empty message is arole="status"after the list, so the listbox holds only options and groups and the message is announced. - Labels
- Name the list with
aria-labelledbyoraria-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
| Key | Behaviour |
|---|---|
| โorโ | Move the highlight to the next or the previous option, stopping at the ends. |
| HomeorEnd | On the list, move the highlight to the first or the last option. |
| Space | On the list, chooses the highlighted option; with multiple, toggles it. |
| Enter | Chooses the highlighted option; with multiple, toggles it. Works from the filter field too. |
| AโZ | On 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.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | string | string[] | null | The value it starts with, when it controls itself. | |
disabled | boolean | false | Makes the list read-only and greys it; it leaves the tab order. |
filter | boolean | false | Shows a field above the list that filters the options as people type. |
filterPlaceholder | string | The filter field's placeholder. | |
form | string | The id of the form the values belong to, for a list placed outside it. | |
indicator | "none" | "checkbox" | "check" | none | none marks chosen options with the fill only; check adds a check at the start; checkbox draws a checkbox. |
listClassName | string | Classes for the scrolling list inside the frame, such as a maximum height. | |
multiple | boolean | false | Lets people choose several options; Space and Enter toggle the highlighted one. |
name | string | Submits 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. | |
value | string | string[] | null | The 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.
| Prop | Type | Default | Description |
|---|---|---|---|
valuerequired | string | The value this option chooses. | |
description | ReactNode | A second, muted line under the label. | |
disabled | boolean | false | The option cannot be highlighted or chosen. |
icon | ReactNode | Leading media beside both lines, such as an avatar or a flag. | |
textValue | string | The 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.
| Token | Used for |
|---|---|
--accent | background |
--accent-foreground | text |
--border | background |
--control | border, background |
--field | background |
--field-disabled | background |
--field-disabled-foreground | text |
--foreground | text |
--highlight | background |
--highlight-focus | background |
--highlight-foreground | text, border |
--invalid | border |
--muted-foreground | text |
--primary | border, background |
--primary-foreground | text |
--ring | border |