Skip to contentSkip to navigationSkip to topbar
Paste assistant Assistant
Figma
Star

Listbox Primitive

Version 3.0.1GithubStorybook

An unstyled and accessible basis upon which to build listboxes.


Component preview theme
const DefaultExample = () => {
const [items] = React.useState(['Item 1', 'Item 2', 'Item 3']);
const [selected, setSelected] = React.useState();
const listbox = useListboxPrimitiveState();
return (
<ListboxPrimitive {...listbox} aria-label="My listbox">
<Stack orientation="vertical" spacing="space0">
{items.map((item) => (
<ListboxPrimitiveItem
key={item}
{...listbox}
selected={selected === item}
onSelect={() => {
setSelected(item);
}}
style={{
...(selected === item && {backgroundColor: '#0263e0', color: 'white'})
}}
>
{item}
</ListboxPrimitiveItem>
))}
</Stack>
</ListboxPrimitive>
)
};
render(
<DefaultExample />
)

Guidelines

Guidelines page anchor

About the Listbox Primitive

About the Listbox Primitive page anchor

The Listbox primitive is an unstyled, functional version of a listbox component. It can be used to build a component following the WAI-ARIA Listbox Pattern(link takes you to an external page).

This unstyled primitive is to be used when the listbox components, like FormPillGroup, provided by Paste dont meet the requirements needed to solve a unique customer problem. At that point, you are welcome to fall back to this functional primitive to roll your own styled Listbox while still providing a functional and accessible experience to your customers.

This primitive should be used to compose all custom Listboxes to ensure accessibility and upgrade paths.

(warning)

Before you roll your own listboxes...

We strongly suggest that all components built on top of this primitive get reviewed by the Design Systems team and go through the UX Review process to ensure an excellent experience for our customers.

To make items inside the Listbox selectable, you can manage the selection state yourself and combine it with the state returned from the useListboxPrimitiveState hook. To do so, track which item is selected in a separate store of state. When rendering the Listbox, cross reference the rendered items with the selection state, and conditionally set selected on each ListboxPrimitiveItem that requires it.

The onSelect event handler will fire whenever the item is clicked, or the spacebar or enter key is pressed. Use this to respond to your users' selection interactions.

Our listbox is vertical by default. To change it to horizontal, specify the orientation when initializing the state with useListboxPrimitiveState({orientation: 'horizontal'}):

Component preview theme
const HorizontalExample = () => {
const [items] = React.useState(['Item 1', 'Item 2', 'Item 3']);
const [selected, setSelected] = React.useState();
const listbox = useListboxPrimitiveState({orientation: 'horizontal'});
return (
<ListboxPrimitive {...listbox} aria-label="My listbox">
{items.map((item) => (
<ListboxPrimitiveItem
key={item}
{...listbox}
selected={selected === item}
onSelect={() => {
setSelected(item);
}}
style={{
...(selected === item && {backgroundColor: '#0263e0', color: 'white'})
}}
>
{item}
</ListboxPrimitiveItem>
))}
</ListboxPrimitive>
)
};
render(
<HorizontalExample />
)

Use ListboxPrimitiveGroup to create different groupings within the same listbox:

Component preview theme
const GroupsExample = () => {
const [items] = React.useState(['Item 1', 'Item 2', 'Item 3']);
const [selected, setSelected] = React.useState();
const listbox = useListboxPrimitiveState();
return (
<ListboxPrimitive {...listbox} aria-label="My listbox">
<ListboxPrimitiveGroup aria-labelledby="group-1">
<Stack orientation="vertical" spacing="space0">
<span id="group-1">Even</span>
{items.filter((item, index) => (index + 1) % 2 === 0).map((item) =>
<ListboxPrimitiveItem
key={item}
{...listbox}
selected={selected === item}
onSelect={() => {
setSelected(item);
}}
style={{
...(selected === item && {backgroundColor: '#0263e0', color: 'white'})
}}
>
{item}
</ListboxPrimitiveItem>
)}
</Stack>
</ListboxPrimitiveGroup>
<ListboxPrimitiveGroup aria-labelledby="group-2">
<Stack orientation="vertical" spacing="space0">
<span id="group-2">Odd</span>
{items.filter((item, index) => (index + 1) % 2 === 1).map((item) =>
<ListboxPrimitiveItem
key={item}
{...listbox}
selected={selected === item}
onSelect={() => {
setSelected(item);
}}
style={{
...(selected === item && {backgroundColor: '#0263e0', color: 'white'})
}}
>
{item}
</ListboxPrimitiveItem>
)}
</Stack>
</ListboxPrimitiveGroup>
</ListboxPrimitive>
)
};
render(
<GroupsExample />
)

Our listbox is set for single selection by default. To change it to allow multiple selections, specify the variant as multiple on ListboxPrimitive:

Component preview theme
const MultipleExample = () => {
const [items] = React.useState(['Item 1', 'Item 2', 'Item 3']);
const [selectedSet, updateSelectedSet] = React.useState(new Set());
const listbox = useListboxPrimitiveState({orientation: 'horizontal'});
return (
<ListboxPrimitive {...listbox} aria-label="My listbox" variant="multiple">
{items.map((item) => (
<ListboxPrimitiveItem
key={item}
{...listbox}
selected={selectedSet.has(item)}
onSelect={() => {
const newSelectedSet = new Set(selectedSet);
if (newSelectedSet.has(item)) {
newSelectedSet.delete(item);
} else {
newSelectedSet.add(item);
}
updateSelectedSet(newSelectedSet);
}}
style={{
...(selectedSet.has(item) && {backgroundColor: '#0263e0', color: 'white'})
}}
>
{item}
</ListboxPrimitiveItem>
))}
</ListboxPrimitive>
)
};
render(
<MultipleExample />
)

Listboxes can be used in any situation where a user needs to select items in a list or rearrange a list. One example is a UI where users need to move items between two lists like this:

Component preview theme
const DualExample = () => {
const [components, updateComponents] = React.useState(['Alert', 'Anchor', 'Button', 'Card', 'Heading', 'List']);
const [favs, updateFavs] = React.useState(['Modal']);
const [selectedComps, updateSelectedComps] = React.useState(new Set());
const [selectedFavs, updateSelectedFavs] = React.useState(new Set());
const compListbox = useListboxPrimitiveState();
const favListbox = useListboxPrimitiveState();
return (
<Grid gutter="space30">
<Column>
<ListboxPrimitive {...compListbox} aria-label="Components" variant="multiple" as={Box} height="300px">
<Stack orientation="vertical" spacing="space40">
{components.map((item) => (
<ListboxPrimitiveItem
as={Button}
size="small"
key={item}
{...compListbox}
selected={selectedComps.has(item)}
onSelect={() => {
const newSelectedComps = new Set(selectedComps);
if (newSelectedComps.has(item)) {
newSelectedComps.delete(item);
} else {
newSelectedComps.add(item);
}
updateSelectedComps(newSelectedComps);
}}
>
{selectedComps.has(item) && <CheckboxCheckIcon decorative />}
{item}
</ListboxPrimitiveItem>
))}
</Stack>
</ListboxPrimitive>
<Button variant="primary_icon" onClick={() => {
updateFavs([...favs, ...Array.from(selectedComps)]);
updateComponents(components.filter((item) => !selectedComps.has(item)));
selectedComps.clear();
}}
>
Add <PlusIcon decorative={false} title="Add items" />
</Button>
</Column>
<Column>
<ListboxPrimitive {...favListbox} aria-label="Favorite components" variant="multiple" as={Box} height="300px">
<Stack orientation="vertical" spacing="space40">
{favs.map((item) => (
<ListboxPrimitiveItem
as={Button}
size="small"
key={item}
{...favListbox}
selected={selectedFavs.has(item)}
onSelect={() => {
const newSelectedFavs = new Set(selectedFavs);
if (newSelectedFavs.has(item)) {
newSelectedFavs.delete(item);
} else {
newSelectedFavs.add(item);
}
updateSelectedFavs(newSelectedFavs);
}}
>
{selectedFavs.has(item) && <CheckboxCheckIcon decorative />}
{item}
</ListboxPrimitiveItem>
))}
</Stack>
</ListboxPrimitive>
<Button variant="primary_icon" onClick={() => {
updateComponents([...components, ...Array.from(selectedFavs)]);
updateFavs(favs.filter((item) => !selectedFavs.has(item)));
selectedFavs.clear();
}}
>
Remove <MinusIcon decorative={false} title="Remove items" />
</Button>
</Column>
</Grid>
)
};
render(
<DualExample />
)

You can provide custom styling to the primitive listbox by utilizing the as prop on each component.

The listbox primitive does not come with any styling, and thus you could mix the functionality of it with another component by using the as prop. By doing so, you can get styling from another component, and listbox functionality from this primitive.

Because these are not styled, rendering any of them as another component you can mix the functionality of two components together. Styling from one, listbox functionlity from the primitive component.

In the example below, we import the Paste Button import {Button} from '@twilio-paste/button'; and use it as the Listbox items via the as prop.

Component preview theme
const CustomExample = () => {
const [items] = React.useState(['Item 1', 'Item 2', 'Item 3']);
const [selected, setSelected] = React.useState();
const listbox = useListboxPrimitiveState({orientation: 'horizontal'});
return (
<ListboxPrimitive {...listbox} aria-label="My listbox">
<Stack orientation="horizontal" spacing="space40">
{items.map((item) => (
<ListboxPrimitiveItem
as={Button}
key={item}
{...listbox}
selected={selected === item}
onSelect={() => {
setSelected(item);
}}
>
{selected === item && <CheckboxCheckIcon decorative />}
{item}
</ListboxPrimitiveItem>
))}
</Stack>
</ListboxPrimitive>
)
};
render(
<CustomExample />
)

This package is a wrapper around the Reakit Composite(link takes you to an external page). If you’re wondering why we wrapped that package into our own, we reasoned that it would be best for our consumers’ developer experience. For example:

  • If we want to migrate the underlying nuts and bolts in the future, Twilio products that depend on this primitive would need to replace all occurrences of import … from ‘x-package’ to import … from ‘@some-new/package’. By wrapping it in @twilio-paste/x-primitive, this refactor can be avoided. The only change would be a version bump in the ‘package.json` file for the primitive.
  • We can more strictly enforce semver and backwards compatibility than some of our dependencies.
  • We can control when to provide an update and which versions we allow, to help reduce potential bugs our consumers may face.
  • We can control which APIs we expose. For example, we may chose to enable or disable usage of certain undocumented APIs.

Installation

Installation page anchor
yarn add @twilio-paste/listbox-primitive - or - yarn add @twilio-paste/core

This props list is a scoped version of the properties available from the Reakit Composite(link takes you to an external page) package.

useListboxPrimitiveState
useListboxPrimitiveState page anchor
PropTypeDescriptionDefault
baseId?stringID that will serve as a base for all the items IDs
rtl?booleanDetermines how next and previous functions will behave
orientation?"horizontal" \| "vertical"Defines the orientation of the listbox"vertical"`
currentId?stringThe current focused item id

Note: Most required props are provided by spreading the returned state from useListboxPrimitiveState.

PropTypeDescriptionDefault
move(id: string \| null) => voidMoves focus to a given item ID
first() => voidMoves focus to the first item
last() => voidMoves focus to the last item
itemsItem[]Lists all the listbox items with their id, DOM ref, disabled state and groupId if any. It is updated when registerItem and unregisterItem are called.
setCurrentIdDispatch<SetStateAction<string \| null \| undefined>>Sets currentId. It only updates the currentId state without moving focus.
focusable?booleanWhen an element is disabled, it may still be focusable. It works similarly to readOnly on form elements. In this case, only aria-disabled will be set.
disabled?booleanSame as the HTML attribute
baseId?stringID that will serve as a base for all the items IDs
currentId?stringThe current focused item id
groups?Group[]Lists all the composite groups with their id and DOM ref. It is updated when registerGroup and unregisterGroup are called.
orientation?"horizontal" \| "vertical"Defines the orientation of the listbox"vertical"`
variant?"single" \| "multiple"Defines the selection mode of the listbox"single"`

No props to show

Note: Most required props are provided by spreading the returned state from useListboxPrimitiveState.

PropTypeDescriptionDefault
baseIdstringID that will serve as a base for all the items IDs
setBaseIdDispatch<SetStateAction<string>>Sets the baseId
itemsItem[]Lists all the listbox items with their id, DOM ref, disabled state and groupId if any. It is updated when registerItem and unregisterItem are called.
groupsGroup[]Lists all the composite groups with their id and DOM ref. It is updated when registerGroup and unregisterGroup are called.
rtlbooleanDetermines how next and previous functions will behave
loopboolean \| "horizontal" \| "vertical"Determines how the keyboard navigation loops through the items'horizontal'
registerItem(item: Item) => voidRegisters a composite item.
unregisterItem(item: Item) => voidUnregisters a composite item.
registerGroup(group: Group) => voidRegisters a composite group.
unregisterGroup(group: Group) => voidUnregisters a composite group.
move(id: string \| null) => voidMoves focus to a given item ID
next() => voidMoves focus to the next item
previous() => voidMoves focus to the previous item
up() => voidMoves focus to the item above
down() => voidMoves focus to the item below
first() => voidMoves focus to the first item
last() => voidMoves focus to the last item
sort() => voidSorts the items based on their position in the DOM
setRTLDispatch<SetStateAction<boolean>>Sets rtl
setOrientationDispatch<SetStateAction<boolean \| "horizontal" \| "vertical">>Sets orientation
setCurrentIdDispatch<SetStateAction<string \| null \| undefined>>Sets currentId. It only updates the currentId state without moving focus.
setLoopDispatch<SetStateAction<boolean \| "horizontal" \| "vertical">>Sets loop
reset() => voidResets to the initial state
selected?booleanSet if an item is in a selected state
disabled?booleanSet if an item is disabled
onSelect?() => voidEvent handler called when an item is selected
currentId?stringThe current focused item id
orientation?"horizontal" \| "vertical"Defines the orientation of the listbox"vertical"`