useCombobox
The problem
You have a combobox/autocomplete dropdown in your application and you want
it to be accessible and functional. For consistency reasons you want it to
follow the ARIA design pattern for a combobox. You also want
this solution to be simple to use and flexible so you can tailor it further to
your specific needs.
This solution
useCombobox is a React hook that manages all the stateful logic needed to
make the combobox functional and accessible. It returns a set of props that are
meant to be called and their results destructured on the combobox's elements:
its label, toggle button, input, combobox container, list and list items. The
props are similar to the ones provided by the Downshift component to its
children via render prop.
These props are called getter props and their return values are destructured as
a set of ARIA attributes and event listeners. Together with the action props and
state props, they create all the stateful logic needed for the combobox to
implement the corresponding ARIA pattern. Every functionality needed should be
provided out-of-the-box: menu toggle, item selection and up/down movement
between them, screen reader support, focus management etc.
Breaking Changes in v8
useCombobox has been affected by breaking changes in v8, so check out the
migration page.
Breaking Changes in v7
Since version 7, useCombobox to supports the ARIA 1.2 pattern for the
combobox, which contains some changes from the ARIA 1.1 pattern. This brings
changes in the API and the behaviour of useCombobox, detailed in the
migration page.
Props used in examples
In the examples below, we use the useCombobox hook and destructure from its
result the getter props and state variables. The hooks also has the
onInputValueChange prop passed in order to filter the items in the list
according to the input value. The getter props are used as follows:
Returned prop | Element | Comments |
---|
getLabelProps | <label> | Call and destructure its returned object on the label element. |
getToggleButtonProps | <button> | Call and destructure its returned object on the toggle button (if any). |
getInputProps | <input> | Call and destructure its returned object on the input element. |
getMenuProps | <ul> | Call and destructure its returned object on the menu element. |
getItemProps | <li> | Call with index or item and destructure its returned object on each menu item element. |
isOpen | | State value with the open state of the menu. Used below for conditionally showing the items. |
highlightedIndex | | State value with the index of thehighlighted menu item. Used below for styling. |
selectedItem | | State value with the item that is selected. Used below for styling. |
inputValue | | State value with the search query. Used below for filtering the items. |
For a complete documentation on all the returned props, hook props and more
information check out the Github Page.
Basic Usage
A combobox element can be created with HTML elements such as: label,
ul, li, button, input and a div or something similar to
contain the input and the toggle button. It is absolutely important to follow
the HTML structure below, as it will allow all screen readers to properly work
with the widget. Most importantly, the input needs to be contained by the
combobox div and the ul needs to be at the same level with the combobox
div.
CodeSandbox for basic usage example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
itemToString(item) {
return item ? item.title : ''
},
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={item.id}
{...getItemProps({item, index})}
>
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</li>
))}
</ul>
</div>
)
}
return <ComboBox />
}
React Native
The hook can be used with React Native as well. The HTML elements and styles
are replaced with React Native equivalents, but the useCombobox usage is
exactly the same as on React web.
MaterialUI
A custom combobox/autocomplete element can be created using UI Library
components as well. Many libraries will provide basic elements such as buttons,
texts/labels, inputs and lists, which can be styled according to each library
guidelines. useCombobox is providing the additional stateful logic that will
transform this selection of basic components into a fully working dropdown
component.
As useCombobox needs to perform some focus() and scroll() logic on the DOM
elements, it will require the refs to the React components used. The example
below will illustrate how to use useCombobox with MaterialUI library
components and how to correctly pass refs to the hook where needed.
Since MaterialUI components already accept a ref prop that will be filled
with the resulting DOM element, we don't need to do anything specific rather
than just spreading the getter props, apart from the case of the Input, which
renders a wrapper element over the actual HTML input. In this case, since
Input provides a prop for accessing the input element called inputRef, we
will use the getter function like this: getInputProps({refKey: 'inputRef'}).
Another point worth mentioning is that in this case items are objects and not
strings. As a result, the itemToString prop is passed to useCombobox. It
will return the string equivalent of the item which will be used for displaying
the item in the input once selected and for the a11y aria-live message that
will occur on every item selection: ${itemToString(item)} has been selected.
item.title is chosen to be the string equivalent of each item object, so our
prop will be passed as itemToString: item => item ? item.title : ''. Since
clearing the input by Escape key is also considered an element change, we will
return an empty string in this case.
CodeSandbox for MaterialUI usage example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const {
inputValue,
selectedItem,
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
highlightedIndex,
getItemProps,
getInputProps,
} = useCombobox({
items,
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
itemToString(item) {
return item ? item.title : ''
},
})
return (
<Box>
<Box className="w-72 flex flex-col gap-1">
<FormLabel className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</FormLabel>
<Box className="flex shadow-sm bg-white gap-0.5">
<Input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps({refKey: 'inputRef'})}
/>
<IconButton
className="px-2"
color="secondary"
{...getToggleButtonProps()}
>
{isOpen ? <ExpandLessIcon /> : <ExpandMoreIcon />}
</IconButton>
</Box>
</Box>
<List
className={cx(
(!isOpen || !items.length) && 'hidden',
'!absolute bg-white w-72 shadow-md max-h-80 overflow-scroll',
)}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => {
return (
<ListItem
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm',
)}
key={item.id}
{...getItemProps({
item,
index,
})}
>
<ListItemText primary={item.title} secondary={item.author} />
</ListItem>
)
})}
</List>
</Box>
)
}
return <ComboBox />
}
Controlling state
Controlling state is possible by receiving the state changes done by Downshift
via onChange props (onHighlightedIndexChange, onSelectedItemChange,
onStateChange etc.). You can then change them based on your requirements and
pass them back to useCombobox as props, such as for instance
highlightedIndex or selectedItem.
The example below shows how to control selectedItem with the help of
React.useState.
CodeSandbox for controlling state example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const [selectedItem, setSelectedItem] = React.useState(null)
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
itemToString(item) {
return item ? item.title : ''
},
selectedItem,
onSelectedItemChange: ({selectedItem: newSelectedItem}) =>
setSelectedItem(newSelectedItem),
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={item.id}
{...getItemProps({item, index})}
>
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</li>
))}
</ul>
<p className="font-semibold">
{selectedItem
? `You have selected ${selectedItem.title} by ${selectedItem.author}.`
: 'Select a book!'}
</p>
</div>
)
}
return <ComboBox />
}
State Reducer
For an even more granular control of the state changing process, you can add
your own reducer on top of the default one. When stateReducer is called it
will receive the previous state and the actionAndChanges object.
actionAndChanges contains the change type, which explains why the state is
being changed. It also contains the changes proposed by Downshift that
should occur as a consequence of that change type. You are supposed to return
the new state according to your needs.
In the example below, let's say we want to show input characters uppercased all
the time. In stateReducer we wait for the
useCombobox.stateChangeTypes.InputChange event, get the proposed inputValue
from the default reducer, uppercase the value, and return the new value along
with the rest of the changes. We will also uppercase the inputValue also when
a selection is performed, since on item selection the inputValue is changed
based on the string version of the selected item.
In all other state change types, we return Downshift default changes.
CodeSandbox for state reducer example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const stateReducer = React.useCallback((state, actionAndChanges) => {
const {type, changes} = actionAndChanges
switch (type) {
case useCombobox.stateChangeTypes.InputChange:
return {
...changes,
inputValue: changes.inputValue.toUpperCase(),
}
case useCombobox.stateChangeTypes.ItemClick:
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.InputBlur:
return {
...changes,
...(changes.selectedItem && {
inputValue: changes.inputValue.toUpperCase(),
}),
}
default:
return changes
}
}, [])
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
itemToString(item) {
return item ? item.title : ''
},
stateReducer,
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={item.id}
{...getItemProps({item, index})}
>
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</li>
))}
</ul>
</div>
)
}
return <ComboBox />
}
Custom window
When using useCombobox in an iframe or in any other scenario that uses a
window object different than the default browser window, it is required
to provide that window object to the hook as well. Internally, we rely on the
window for DOM related logic and working with the wrong object will make the
hook behave unexpectedly. For example, when using react-frame-component to
produce an iframe container, we should pass its window object to the hook
like shown below.
CodeSandbox for custom window example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const {window} = useFrame()
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
itemToString(item) {
return item ? item.title : ''
},
environment: window,
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={item.id}
{...getItemProps({item, index})}
>
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</li>
))}
</ul>
</div>
)
}
return (
<Frame className="h-60 w-full">
<link href="/styles.css" rel="stylesheet" />
<ComboBox />
</Frame>
)
}
Basic Multiple Selection
The useCombobox hook can be used to create a widget that supports multiple
selection. In the example below, we mark each selected item with a checked
checkbox inside the menu list. Every other aspect remains the same as with the
single selection combobox. For a more interactive example of multiple selection,
you can use our useMultipleSelection hook together with useCombobox, as
shown in the
multiple selection section.
In the example below, we control the selectedItem to always be null and keep
our selected items in a state variable, selectedItems. We use
onSelectedItemChange prop to retrieve the selectedItem from useCombobox,
which is added to / removed from the selectedItems array. We also use
stateReducer to keep the menu open on selection by Enter key or by click, and
also to keep the highlightedIndex to be the most recent selected item.
In order to visually illustrate the selection, we render a checkbox before each
of them and check only the ones that are selected.
CodeSandbox for basic multiple selection
example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const [selectedItems, setSelectedItems] = useState([])
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
selectedItem: null,
itemToString(item) {
return item ? item.title : ''
},
onSelectedItemChange: ({selectedItem}) => {
if (!selectedItem) {
return
}
const index = selectedItems.indexOf(selectedItem)
if (index > 0) {
setSelectedItems([
...selectedItems.slice(0, index),
...selectedItems.slice(index + 1),
])
} else if (index === 0) {
setSelectedItems([...selectedItems.slice(1)])
} else {
setSelectedItems([...selectedItems, selectedItem])
}
},
selectedItem: null,
stateReducer: (state, actionAndChanges) => {
const {changes, type} = actionAndChanges
switch (type) {
case useCombobox.stateChangeTypes.InputKeyDownEnter:
case useCombobox.stateChangeTypes.ItemClick:
return {
...changes,
isOpen: true,
highlightedIndex: state.highlightedIndex,
inputValue: '',
}
case useCombobox.stateChangeTypes.InputBlur:
return {
...changes,
inputValue: '',
}
default:
return changes
}
},
})
const inputPlaceholder = selectedItems.length
? `${selectedItems.length} books selected.`
: 'Best books ever.'
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite books:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder={inputPlaceholder}
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItems.includes(item) && 'font-bold',
'py-2 px-3 shadow-sm flex gap-3 items-center',
)}
key={item.id}
{...getItemProps({
item,
index,
'aria-selected': selectedItems.includes(item),
})}
>
<input
type="checkbox"
className="h-5 w-5"
checked={selectedItems.includes(item)}
value={item}
onChange={() => null}
/>
<div className="flex flex-col">
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</div>
</li>
))}
</ul>
</div>
)
}
return <ComboBox />
}
Using action props
Action props are functions returned by useCombobox along with the state props
and getter props. They are handy when you need to execute combobox state changes
from event handlers, state change handlers or any other external location. In
the example below we clear the selection by clicking on the custom selection
clearing button. We use the selectItem action prop in order to achieve this
custom behavior.
CodeSandbox for action props example.
function ComboBoxExample() {
const books = [
{id: 'book-1', author: 'Harper Lee', title: 'To Kill a Mockingbird'},
{id: 'book-2', author: 'Lev Tolstoy', title: 'War and Peace'},
{id: 'book-3', author: 'Fyodor Dostoyevsy', title: 'The Idiot'},
{id: 'book-4', author: 'Oscar Wilde', title: 'A Picture of Dorian Gray'},
{id: 'book-5', author: 'George Orwell', title: '1984'},
{id: 'book-6', author: 'Jane Austen', title: 'Pride and Prejudice'},
{id: 'book-7', author: 'Marcus Aurelius', title: 'Meditations'},
{
id: 'book-8',
author: 'Fyodor Dostoevsky',
title: 'The Brothers Karamazov',
},
{id: 'book-9', author: 'Lev Tolstoy', title: 'Anna Karenina'},
{id: 'book-10', author: 'Fyodor Dostoevsky', title: 'Crime and Punishment'},
]
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
selectItem,
} = useCombobox({
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
items,
itemToString(item) {
return item ? item.title : ''
},
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="clear selection"
className="px-2"
type="button"
onClick={() => {
selectItem(null)
}}
tabIndex={-1}
>
×
</button>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps()}
>
{isOpen &&
items.map((item, index) => (
<li
className={cx(
highlightedIndex === index && 'bg-blue-300',
selectedItem === item && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={item.id}
{...getItemProps({item, index})}
>
<span>{item.title}</span>
<span className="text-sm text-gray-700">{item.author}</span>
</li>
))}
</ul>
</div>
)
}
return <ComboBox />
}
Virtualizing items with react-virtual
When the number of items in the dropdown is too big, you may want to consider
using a virtualization technique to avoid loss in performance due to unnecessary
elements rendered in the DOM. react-virtual is a great
library to provide items virtualization and it's the one we will show in the
example below. There are other libraries as well, such as
react-virtualized and
react-virtual.
Since react-virtual has its own scrolling library, we will use it instead of
the default one from Downshift. Apart from this it's business as usual in both
the case of using useCombobox and useVirtual, about which you can learn in
the react-virtual github link.
CodeSandbox for virtualized list example.
function ComboBoxExample() {
const books = []
for (let index = 1; index <= 1000; index++) {
books.push({author: `Author ${index}`, title: `Book Number ${index}`})
}
function getBooksFilter(inputValue) {
const lowerCasedInputValue = inputValue.toLowerCase()
return function booksFilter(book) {
return (
!inputValue ||
book.title.toLowerCase().includes(lowerCasedInputValue) ||
book.author.toLowerCase().includes(lowerCasedInputValue)
)
}
}
function estimateSize() {
return 60
}
function ComboBox() {
const [items, setItems] = React.useState(books)
const listRef = useRef()
const rowVirtualizer = useVirtual({
size: items.length,
parentRef: listRef,
estimateSize,
overscan: 2,
})
const {
isOpen,
getToggleButtonProps,
getLabelProps,
getMenuProps,
getInputProps,
highlightedIndex,
getItemProps,
selectedItem,
} = useCombobox({
items,
onInputValueChange({inputValue}) {
setItems(books.filter(getBooksFilter(inputValue)))
},
itemToString(item) {
return item ? item.title : ''
},
scrollIntoView() {},
onHighlightedIndexChange: ({highlightedIndex, type}) => {
if (type !== useCombobox.stateChangeTypes.MenuMouseLeave) {
rowVirtualizer.scrollToIndex(highlightedIndex)
}
},
})
return (
<div>
<div className="w-72 flex flex-col gap-1">
<label className="w-fit" {...getLabelProps()}>
Choose your favorite book:
</label>
<div className="flex shadow-sm bg-white gap-0.5">
<input
placeholder="Best book ever"
className="w-full p-1.5"
{...getInputProps()}
/>
<button
aria-label="toggle menu"
className="px-2"
type="button"
{...getToggleButtonProps()}
>
{isOpen ? <>↑</> : <>↓</>}
</button>
</div>
</div>
<ul
className={`absolute w-72 bg-white mt-1 shadow-md max-h-80 overflow-scroll p-0 z-10 ${
!(isOpen && items.length) && 'hidden'
}`}
{...getMenuProps({ref: listRef})}
>
{isOpen && (
<>
<li key="total-size" style={{height: rowVirtualizer.totalSize}} />
{rowVirtualizer.virtualItems.map(virtualRow => (
<li
className={cx(
highlightedIndex === virtualRow.index && 'bg-blue-300',
selectedItem === items[virtualRow.index] && 'font-bold',
'py-2 px-3 shadow-sm flex flex-col',
)}
key={items[virtualRow.index].id}
{...getItemProps({
index: virtualRow.index,
item: items[virtualRow.index],
})}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<span>{items[virtualRow.index].title}</span>
<span className="text-sm text-gray-700">
{items[virtualRow.index].author}
</span>
</li>
))}
</>
)}
</ul>
</div>
)
}
return <ComboBox />
}
Other usage examples
To see more cool stuff you can build with useCombobox, explore the examples
repository.