Listbox
A control for selecting a value from a list of options.
import { useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxPreview() {
const [selectedPerson, setSelectedPerson] = useState(people[0]);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger>{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
{people.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}Dependencies
Source Code
import {
autoUpdate,
FloatingList,
flip,
offset,
type Placement,
shift,
size,
type UseFloatingReturn,
type UseInteractionsReturn,
useClick,
useDismiss,
useFloating,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
useTypeahead,
} from "@floating-ui/react";
import { CaretUpDownIcon, CheckIcon } from "@phosphor-icons/react/dist/ssr";
import type { VariantProps } from "cva";
import {
Children,
createContext,
isValidElement,
use,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { Divider } from "@/components/divider";
import { inputStyle } from "@/components/input";
import { Popover } from "@/components/popover";
import { cn } from "@/lib/utils/classnames";
// Utils
const hasChildren = (props: unknown): props is { children: React.ReactNode } => {
return typeof props === "object" && props !== null && "children" in props;
};
export function getTextContent(children: React.ReactNode): string {
if (typeof children === "string") return children;
if (typeof children === "number") return children.toString();
if (Array.isArray(children)) return children.map(getTextContent).join("");
if (isValidElement(children) && hasChildren(children.props))
return getTextContent(children.props.children);
return "";
}
// Context
type Option<T = string> = {
value: T;
label: string;
disabled?: boolean;
};
interface ListboxContextType<T = string> extends UseFloatingReturn, UseInteractionsReturn {
elementsRef: React.RefObject<(HTMLElement | null)[]>;
labelsRef: React.RefObject<string[]>;
setOptions: (options: Option<T>[]) => void;
highlightedIndex: number | null;
setHighlightedIndex: React.Dispatch<React.SetStateAction<number | null>>;
value: T | undefined;
handleSelect: (index: number | null) => void;
invalid?: boolean;
disabled?: boolean;
setIsSearchable: React.Dispatch<React.SetStateAction<boolean>>;
getIsSelected: (a: T, b: T) => boolean;
}
// biome-ignore lint/suspicious/noExplicitAny: __
const ListboxContext = createContext<ListboxContextType<any> | null>(null);
const useListboxContext = <T,>() => {
const context = use(ListboxContext as React.Context<ListboxContextType<T> | null>);
if (context == null) {
throw new Error("useListboxContext must be used within a Listbox");
}
return context;
};
// Utils
const isObjectWithId = (value: unknown): value is { id: unknown } => {
return typeof value === "object" && value !== null && "id" in value;
};
const isPrimitive = (value: unknown): value is string | number | boolean => {
return typeof value !== "object" && value !== null;
};
const defaultGetIsSelected = <T,>(a: T, b: T) => {
if (isObjectWithId(a) && isObjectWithId(b)) {
return a.id === b.id;
}
if (isPrimitive(a) && isPrimitive(b)) {
return a === b;
}
return JSON.stringify(a) === JSON.stringify(b);
};
// Chore mechanics (Floating UI)
interface UseListboxFloatingOptions<T = string> {
value: T;
onChange: (value: T) => void;
disabled?: boolean;
invalid?: boolean;
placement?: Placement;
getIsSelected?: (a: T, b: T) => boolean;
matchReferenceWidth?: boolean;
}
const useListboxFloating = <T,>({
value,
onChange,
disabled,
invalid,
placement = "bottom",
getIsSelected = defaultGetIsSelected,
matchReferenceWidth = true,
}: UseListboxFloatingOptions<T>) => {
const [open, setOpen] = useState(false);
const [isSearchable, setIsSearchable] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
const [options, setOptions] = useState<Option<T>[]>([]);
const elementsRef = useRef<(HTMLElement | null)[]>([]);
const selectedIndex = useMemo(() => {
if (!value) return -1;
return options.findIndex((option) => {
return getIsSelected(option.value, value);
});
}, [options, value, getIsSelected]);
const floating = useFloating({
placement,
open,
onOpenChange: setOpen,
whileElementsMounted: (reference, floating, update) =>
autoUpdate(reference, floating, update, {
layoutShift: false,
}),
middleware: [
flip({ padding: 4 }),
shift({ padding: 4 }),
offset(4),
size({
apply({ rects, elements, availableHeight }) {
elements.floating.style.setProperty("--max-height", `${availableHeight}px`);
if (matchReferenceWidth) {
elements.floating.style.setProperty("--width", `${rects.reference.width}px`);
}
},
padding: 4,
}),
],
});
const handleSelect = useCallback(
(index: number | null) => {
if (index === null) return;
const option = options[index];
if (!option) return;
// assuming that a `value` array means a multiselect (sorry tuples)
const isMultiple = Array.isArray(value);
if (isMultiple) {
const isSelected = value.some((v) => getIsSelected(option.value, v));
return isSelected
? onChange(value.filter((v) => !getIsSelected(option.value, v)) as T)
: onChange([...value, option.value] as T);
}
onChange(option.value);
setOpen(false);
},
[onChange, options, value, getIsSelected],
);
const listNav = useListNavigation(floating.context, {
listRef: elementsRef,
activeIndex: highlightedIndex,
selectedIndex,
onNavigate: setHighlightedIndex,
virtual: isSearchable || undefined,
loop: isSearchable || undefined,
});
const handleTypeaheadMatch = useCallback(
(index: number | null) => {
if (open) {
setHighlightedIndex(index);
} else {
handleSelect(index);
}
},
[open, handleSelect],
);
const labelsRef = useRef<string[]>([]);
useEffect(() => {
labelsRef.current = options.map((option) => option.label);
}, [options]);
const typeahead = useTypeahead(floating.context, {
enabled: !(isSearchable && open),
listRef: labelsRef,
activeIndex: highlightedIndex,
selectedIndex,
onMatch: handleTypeaheadMatch,
});
const click = useClick(floating.context, {
enabled: !disabled,
event: "mousedown",
});
const dismiss = useDismiss(floating.context);
const role = useRole(floating.context, { role: "listbox" });
const interactions = useInteractions([listNav, typeahead, click, dismiss, role]);
return useMemo(
() => ({
elementsRef,
labelsRef,
highlightedIndex,
setHighlightedIndex,
value,
invalid,
disabled,
setOptions,
handleSelect,
setIsSearchable,
getIsSelected,
...interactions,
...floating,
}),
[
highlightedIndex,
value,
invalid,
disabled,
handleSelect,
getIsSelected,
interactions,
floating,
],
);
};
// Components
type ListboxProps<T = string> = UseListboxFloatingOptions<T> & {
children: React.ReactNode;
};
/**
* Listbox is a controlled component used for choosing a value from a list of options, typically in forms.
* It's best suited for scenarios where users need to pick an item from a predefined set.
*
* Use Listbox when choosing a value from a list of options.
* Use Dropdown instead when you want to trigger actions or navigation.
*/
const Listbox = <T,>({
children,
ref,
...props
}: ListboxProps<T> & { ref?: React.Ref<ListboxContextType<T>> }) => {
const contextValue = useListboxFloating(props);
useImperativeHandle(ref, () => contextValue);
return <ListboxContext value={contextValue}>{children}</ListboxContext>;
};
interface ListboxTriggerProps extends React.ComponentPropsWithRef<"button"> {
variant?: VariantProps<typeof inputStyle>["variant"];
placeholder?: string;
}
const ListboxTrigger = ({
ref: refProp,
children,
className,
variant,
placeholder,
...props
}: ListboxTriggerProps) => {
const ctx = useListboxContext();
const ref = useMergeRefs([ctx.refs.setReference, refProp]);
return (
<ListboxButton
ref={ref}
placeholder={placeholder}
variant={variant}
className={className}
disabled={ctx.disabled}
data-state={ctx.context.open ? "open" : "closed"}
data-invalid={ctx.invalid || undefined}
{...ctx.getReferenceProps(props)}
>
{children}
</ListboxButton>
);
};
interface ListboxButtonProps extends React.ComponentPropsWithRef<"button"> {
placeholder?: string;
variant?: VariantProps<typeof inputStyle>["variant"];
}
/**
* ListboxButton is a button that mimics a select input style.
*/
const ListboxButton = ({
ref,
children,
placeholder,
variant,
className,
...props
}: ListboxButtonProps) => {
return (
<button
ref={ref}
type="button"
className={cn(
inputStyle({ variant }),
"flex items-center gap-1.5 enabled:cursor-pointer",
"relative w-full pr-10 pl-4",
className,
)}
{...props}
>
<span className="flex flex-1 items-center gap-1.5 truncate text-left">
{children ?? <span className="text-foreground-secondary">{placeholder}</span>}
</span>
<CaretUpDownIcon
weight="bold"
className="absolute top-1/2 right-3 -translate-y-1/2 text-base text-foreground/80"
/>
</button>
);
};
const hasOptionProps = (
props: unknown,
): props is {
value: unknown;
disabled?: boolean;
children?: React.ReactNode;
} => {
return typeof props === "object" && props !== null && "value" in props;
};
const ListboxOptions = <T,>({
ref: refProp,
children,
className,
...props
}: React.ComponentPropsWithRef<"div">) => {
const { setOptions, setIsSearchable, refs, elementsRef, labelsRef, context, getFloatingProps } =
useListboxContext();
useEffect(() => {
const extractOptions = (children: React.ReactNode): Option<T>[] => {
return Children.toArray(children).reduce<Option<T>[]>((acc, child) => {
if (isValidElement(child)) {
if (child.type === ListboxOption && hasOptionProps(child.props)) {
acc.push({
value: child.props.value as T,
label: getTextContent(child.props.children),
disabled: child.props.disabled,
});
} else if (hasChildren(child.props)) {
// Recursively extract options from nested children
acc.push(...extractOptions(child.props.children));
}
}
return acc;
}, []);
};
setOptions(extractOptions(children));
}, [children, setOptions]);
useEffect(() => {
const hasSearchInput = Children.toArray(children).some(
(child) => isValidElement(child) && child.type === ListboxSearchInput,
);
if (hasSearchInput) {
setIsSearchable(true);
}
}, [children, setIsSearchable]);
const ref = useMergeRefs([refs.setFloating, refProp]);
return (
<Popover.Panel
context={context}
ref={ref}
className={cn(
"z-50 flex flex-col items-stretch rounded-xl border border-border bg-background p-0 text-foreground shadow-xl focus:outline-none",
"overflow-y-auto overscroll-contain",
"max-h-(--max-height) w-(--width)",
className,
)}
{...getFloatingProps(props)}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{children}
</FloatingList>
</Popover.Panel>
);
};
interface ListboxOptionProps<T = string>
extends Omit<React.ComponentPropsWithRef<"button">, "children" | "value"> {
children: React.ReactNode;
value: T;
disabled?: boolean;
}
const ListboxOption = <T,>({
ref: refProp,
value,
children,
disabled,
className,
...props
}: ListboxOptionProps<T>) => {
const {
highlightedIndex,
value: contextValue,
getIsSelected,
getItemProps,
handleSelect,
} = useListboxContext<T>();
const label = getTextContent(children);
const { ref: listItemRef, index } = useListItem({ label });
const ref = useMergeRefs([listItemRef, refProp]);
const isHighlighted = highlightedIndex === index;
const isSelected = Array.isArray(contextValue)
? contextValue.some((v) => getIsSelected(v, value))
: contextValue && getIsSelected(contextValue, value);
return (
<button
ref={ref}
type="button"
role="option"
aria-selected={isHighlighted && isSelected}
data-selected={isSelected || undefined}
data-highlighted={isHighlighted || undefined}
tabIndex={isHighlighted ? 0 : -1}
disabled={disabled || undefined}
data-disabled={disabled || undefined}
className={cn(
"relative mx-(--inset) flex cursor-pointer select-none items-center gap-1.5 rounded-lg px-4 py-2 text-left font-medium text-foreground outline-none first-of-type:mt-(--inset) last-of-type:mb-(--inset) data-disabled:pointer-events-none data-highlighted:bg-foreground/5 data-disabled:opacity-50",
"pr-8",
className,
)}
{...getItemProps({
...props,
onClick: (e) => {
handleSelect(index);
props.onClick?.(e as React.MouseEvent<HTMLButtonElement>);
},
})}
>
{children}
{isSelected && (
<CheckIcon
weight="bold"
className="absolute top-1/2 right-3 -translate-y-1/2 text-foreground text-sm"
/>
)}
</button>
);
};
const ListboxDivider = ({
className,
...props
}: Omit<React.ComponentPropsWithRef<"div">, "children">) => {
return <Divider className={cn("my-(--inset)", className)} {...props} />;
};
/**
* SelectSearchInput is meant to render a search input within the select popover options.
* Use it to filter the options based on a search query.
*
* If this component is used, the `selection` placement will be ignored.
*/
interface ListboxSearchInputProps extends React.ComponentPropsWithRef<"input"> {
isLoading?: boolean;
}
const ListboxSearchInput = ({
ref,
onKeyDown,
onChange,
isLoading,
...props
}: ListboxSearchInputProps) => {
const { highlightedIndex, setHighlightedIndex, handleSelect } = useListboxContext();
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(event);
setHighlightedIndex(0);
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
handleSelect(highlightedIndex);
}
onKeyDown?.(event);
};
return (
<Popover.SearchInput
ref={ref}
onKeyDown={handleKeyDown}
onChange={handleChange}
isLoading={isLoading}
{...props}
/>
);
};
const ListboxEmpty = Popover.Empty;
const CompoundListbox = Object.assign(Listbox, {
Trigger: ListboxTrigger,
Button: ListboxButton,
Options: ListboxOptions,
Option: ListboxOption,
Divider: ListboxDivider,
SearchInput: ListboxSearchInput,
Empty: ListboxEmpty,
});
export { CompoundListbox as Listbox };Anatomy
<Listbox value={value} onChange={onChange}>
<Listbox.Trigger />
<Listbox.Options>
<Listbox.SearchInput />
<Listbox.Option />
<Listbox.Divider />
<Listbox.Option />
<Listbox.Empty />
</Listbox.Options>
</Listbox>
Features
- Keyboard Navigation: Use arrow keys to navigate between options, Enter to select, and Escape to close
- Search Functionality: Built-in search input for filtering options
- Multiple Selection: Support for selecting multiple options with checkboxes
- Custom Rendering: Flexible API for custom option rendering
- Form Integration: Works seamlessly with form libraries
- Accessibility: Full ARIA support and keyboard navigation
- Floating UI: Smart positioning that adapts to available space
API Reference
Listbox
| Prop | Default | Type | Description |
|---|---|---|---|
value * | - | T | T[] | The controlled value of the listbox. Can be a single value or an array for multiple selection. |
onChange * | - | (value: T | T[]) => void | Callback fired when the value changes. |
disabled | false | boolean | Whether the listbox is disabled. |
invalid | false | boolean | Whether the listbox is in an invalid state. |
placement | "selection" | "selection" | Placement | The placement of the options relative to the trigger. Use 'selection' to align with the selected option. |
getIsSelected | - | (a: T, b: T) => boolean | Custom comparison function to determine if two values are equal. |
matchReferenceWidth | true | boolean | Whether the options width should match the trigger width. |
Listbox.Trigger
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
variant | "default" | InputProps['variant'] | The visual style variant to use. |
placeholder | - | string | The placeholder text to show when no value is selected. |
Listbox.Options
Extends the div element.
The options will be rendered in a portal and will be positioned relative to the trigger.
Listbox.Option
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
value * | - | T | The value associated with this option. |
disabled | false | boolean | Whether the option is disabled. |
withCheckmark | true | boolean | Whether to show a checkmark when the option is selected. |
Listbox.Divider
A horizontal line to separate groups of options.
Listbox.SearchInput
Extends the input element.
A styled input with a search icon, useful for filtering options.
| Prop | Default | Type | Description |
|---|---|---|---|
isLoading | false | boolean | When true, replaces the leading magnifying-glass icon with a spinner. Useful for async-filtered lists. |
Listbox.Empty
A styled container for empty state messages.
Accessibility
The Listbox component follows the WAI-ARIA Listbox Pattern. It includes proper ARIA attributes and keyboard navigation support.
Keyboard Interactions
SpaceorEnter: Select the focused optionArrowUp/ArrowDown: Navigate between optionsHome/End: Jump to first/last optionEscape: Close the listboxTab: Move focus to the next focusable elementType: Jump to options starting with the typed characters
Examples
Simple
Basic usage with single selection.
import { useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxPreview() {
const [selectedPerson, setSelectedPerson] = useState(people[0]);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger>{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
{people.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}Minimal
Using the minimal variant without borders.
import { useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxMinimalPreview() {
const [selectedPerson, setSelectedPerson] = useState(people[0]);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger variant="minimal">{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
{people.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}With Placeholder
Showing placeholder text when no value is selected.
import { useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxPlaceholderPreview() {
const [selectedPerson, setSelectedPerson] = useState<(typeof people)[number] | null>(null);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger placeholder="Select person">{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
{people.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}With Search
Filtering options with a search input.
import { useMemo, useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxSearchPreview() {
const [search, setSearch] = useState("");
const [selectedPerson, setSelectedPerson] = useState(people[0]);
const filteredPeople = useMemo(() => {
return people.filter((person) => person.name.toLowerCase().includes(search.toLowerCase()));
}, [search]);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger>{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
<Listbox.SearchInput
placeholder="Search people"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{filteredPeople.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
{filteredPeople.length === 0 && <Listbox.Empty>No results</Listbox.Empty>}
</Listbox.Options>
</Listbox>
</div>
);
}Async Search
Debounced search against an async data source. The isLoading prop on Listbox.SearchInput swaps the magnifying-glass icon for a spinner while results are in flight.
import { useEffect, useState } from "react";
import { Listbox } from "@/components/listbox";
const FRUITS = [
"Apple",
"Apricot",
"Avocado",
"Banana",
"Blackberry",
"Blueberry",
"Cherry",
"Coconut",
"Date",
"Dragon Fruit",
"Elderberry",
"Fig",
"Grape",
"Grapefruit",
"Guava",
"Honeydew",
"Kiwi",
"Lemon",
"Lime",
"Mango",
"Nectarine",
"Orange",
"Papaya",
"Passion Fruit",
"Peach",
"Pear",
"Persimmon",
"Pineapple",
"Plum",
"Pomegranate",
"Quince",
"Raspberry",
"Strawberry",
"Tangerine",
"Watermelon",
];
const fakeSearch = (query: string): Promise<string[]> =>
new Promise((resolve) => {
window.setTimeout(() => {
const q = query.toLowerCase();
resolve(FRUITS.filter((f) => f.toLowerCase().includes(q)));
}, 400);
});
export default function ListboxAsyncPreview() {
const [search, setSearch] = useState("");
const [results, setResults] = useState<string[]>(FRUITS);
const [isLoading, setIsLoading] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
setIsLoading(true);
const handle = window.setTimeout(async () => {
const data = await fakeSearch(search);
setResults(data);
setIsLoading(false);
}, 200);
return () => window.clearTimeout(handle);
}, [search]);
return (
<div className="w-80">
<Listbox value={selected} onChange={setSelected}>
<Listbox.Trigger placeholder="Pick a fruit">{selected}</Listbox.Trigger>
<Listbox.Options>
<Listbox.SearchInput
placeholder="Search fruits"
value={search}
onChange={(e) => setSearch(e.target.value)}
isLoading={isLoading}
/>
{results.map((fruit) => (
<Listbox.Option key={fruit} value={fruit}>
{fruit}
</Listbox.Option>
))}
{!isLoading && results.length === 0 && <Listbox.Empty>No results</Listbox.Empty>}
</Listbox.Options>
</Listbox>
</div>
);
}Multiple Selection
Selecting multiple values with checkboxes.
import { useMemo, useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxMultiplePreview() {
const [selectedPeople, setSelectedPeople] = useState<(typeof people)[number][]>([]);
const [search, setSearch] = useState("");
const filteredPeople = useMemo(() => {
return people.filter((person) => person.name.toLowerCase().includes(search.toLowerCase()));
}, [search]);
return (
<div className="w-80">
<Listbox value={selectedPeople} onChange={setSelectedPeople}>
<Listbox.Trigger placeholder="Select people">
{selectedPeople.length > 1
? `${selectedPeople.length} people selected`
: selectedPeople[0]?.name}
</Listbox.Trigger>
<Listbox.Options>
<Listbox.SearchInput
placeholder="Search people"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{filteredPeople.map((person) => (
<Listbox.Option key={person.id} value={person}>
{person.name}
</Listbox.Option>
))}
{filteredPeople.length === 0 && <Listbox.Empty>No results</Listbox.Empty>}
</Listbox.Options>
</Listbox>
</div>
);
}With Divider
Using dividers to group options.
import { Fragment, useState } from "react";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxDividerPreview() {
const [selectedPerson, setSelectedPerson] = useState(people[0]);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger>{selectedPerson?.name}</Listbox.Trigger>
<Listbox.Options>
{people.map((person, i) => (
<Fragment key={person.id}>
<Listbox.Option value={person}>{person.name}</Listbox.Option>
{i === 2 && <Listbox.Divider />}
</Fragment>
))}
</Listbox.Options>
</Listbox>
</div>
);
}Custom Rendering
Complex example with avatars and custom option layout.
import { useState } from "react";
import { Avatar } from "@/components/avatar";
import { Listbox } from "@/components/listbox";
const people = [
{ id: 1, name: "Durward Reynolds" },
{ id: 2, name: "Kenton Towne" },
{ id: 3, name: "Therese Wunsch" },
{ id: 4, name: "Benedict Kessler" },
{ id: 5, name: "Katelyn Rohan" },
];
export default function ListboxCustomPreview() {
const [selectedPerson, setSelectedPerson] = useState<(typeof people)[number] | null>(null);
return (
<div className="w-80">
<Listbox value={selectedPerson} onChange={setSelectedPerson}>
<Listbox.Trigger placeholder="Select person" className={selectedPerson ? "pl-3" : ""}>
{selectedPerson && (
<span className="flex items-center gap-2">
<Avatar size="xs">
<Avatar.Image
src={`https://api.dicebear.com/6.x/thumbs/svg?seed=${selectedPerson.name}`}
/>
<Avatar.Fallback>{selectedPerson.name[0]}</Avatar.Fallback>
</Avatar>
<span>{selectedPerson.name}</span>
</span>
)}
</Listbox.Trigger>
<Listbox.Options>
{people.map((person) => (
<Listbox.Option key={person.id} value={person} className="flex items-center gap-2 px-3">
<Avatar size="xs">
<Avatar.Image src={`https://api.dicebear.com/6.x/thumbs/svg?seed=${person.name}`} />
<Avatar.Fallback>{person.name[0]}</Avatar.Fallback>
</Avatar>
<span>{person.name}</span>
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}Previous
Kbd
Next
Menu