Listbox
A control for selecting a value from a list of options.
'use client';
import { useState } from 'react';
import {
Listbox,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger>{selectedPerson?.name}</ListboxTrigger>
<ListboxOptions>
{people.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
);
} Dependencies
Source Code
'use client';
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';
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 {
PopoverEmpty,
PopoverPanel,
PopoverSearchInput,
} 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 || !options[index]) return;
// assuming that a `value` array means a multiselect (sorry tuples)
const isMultiple = Array.isArray(value);
if (isMultiple) {
const isSelected = value.some((v) =>
getIsSelected(options[index].value, v)
);
return isSelected
? onChange(
value.filter((v) => !getIsSelected(options[index].value, v)) as T
)
: onChange([...value, options[index].value] as T);
}
onChange(options[index].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}
{...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 (
<PopoverPanel
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>
</PopoverPanel>
);
};
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}
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-1 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-1 last-of-type:mb-1 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-1', 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.
*/
const ListboxSearchInput = ({
ref,
onKeyDown,
onChange,
...props
}: React.ComponentPropsWithRef<'input'>) => {
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 (
<PopoverSearchInput
ref={ref}
onKeyDown={handleKeyDown}
onChange={handleChange}
{...props}
/>
);
};
const ListboxEmpty = PopoverEmpty;
export {
Listbox,
ListboxButton,
ListboxDivider,
ListboxEmpty,
ListboxOption,
ListboxOptions,
ListboxSearchInput,
ListboxTrigger,
}; Anatomy
<Listbox value={value} onChange={onChange}>
<ListboxTrigger />
<ListboxOptions>
<ListboxSearchInput />
<ListboxOption />
<ListboxDivider />
<ListboxOption />
<ListboxEmpty />
</ListboxOptions>
</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. |
ListboxTrigger
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. |
ListboxOptions
Extends the div element.
The options will be rendered in a portal and will be positioned relative to the trigger.
ListboxOption
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. |
ListboxDivider
A horizontal line to separate groups of options.
ListboxSearchInput
Extends the input element.
A styled input with a search icon, useful for filtering options.
ListboxEmpty
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.
'use client';
import { useState } from 'react';
import {
Listbox,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger>{selectedPerson?.name}</ListboxTrigger>
<ListboxOptions>
{people.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
);
} Minimal
Using the minimal variant without borders.
'use client';
import { useState } from 'react';
import {
Listbox,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger variant="minimal">
{selectedPerson?.name}
</ListboxTrigger>
<ListboxOptions>
{people.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
);
} With Placeholder
Showing placeholder text when no value is selected.
'use client';
import { useState } from 'react';
import {
Listbox,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger placeholder="Select person">
{selectedPerson?.name}
</ListboxTrigger>
<ListboxOptions>
{people.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
);
} With Search
Filtering options with a search input.
'use client';
import { useMemo, useState } from 'react';
import {
Listbox,
ListboxEmpty,
ListboxOption,
ListboxOptions,
ListboxSearchInput,
ListboxTrigger,
} 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}>
<ListboxTrigger>{selectedPerson?.name}</ListboxTrigger>
<ListboxOptions>
<ListboxSearchInput
placeholder="Search people"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{filteredPeople.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
{filteredPeople.length === 0 && (
<ListboxEmpty>No results</ListboxEmpty>
)}
</ListboxOptions>
</Listbox>
</div>
);
} Multiple Selection
Selecting multiple values with checkboxes.
'use client';
import { useMemo, useState } from 'react';
import {
Listbox,
ListboxEmpty,
ListboxOption,
ListboxOptions,
ListboxSearchInput,
ListboxTrigger,
} 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}>
<ListboxTrigger placeholder="Select people">
{selectedPeople.length > 1
? `${selectedPeople.length} people selected`
: selectedPeople[0]?.name}
</ListboxTrigger>
<ListboxOptions>
<ListboxSearchInput
placeholder="Search people"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{filteredPeople.map((person) => (
<ListboxOption key={person.id} value={person}>
{person.name}
</ListboxOption>
))}
{filteredPeople.length === 0 && (
<ListboxEmpty>No results</ListboxEmpty>
)}
</ListboxOptions>
</Listbox>
</div>
);
} With Divider
Using dividers to group options.
'use client';
import { Fragment, useState } from 'react';
import {
Listbox,
ListboxDivider,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger>{selectedPerson?.name}</ListboxTrigger>
<ListboxOptions>
{people.map((person, i) => (
<Fragment key={person.id}>
<ListboxOption value={person}>{person.name}</ListboxOption>
{i === 2 && <ListboxDivider />}
</Fragment>
))}
</ListboxOptions>
</Listbox>
</div>
);
} Custom Rendering
Complex example with avatars and custom option layout.
'use client';
import { useState } from 'react';
import {
Avatar,
AvatarFallback,
AvatarImage,
} from '@/components/avatar';
import {
Listbox,
ListboxOption,
ListboxOptions,
ListboxTrigger,
} 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}>
<ListboxTrigger
placeholder="Select person"
className={selectedPerson ? 'pl-3' : ''}
>
{selectedPerson && (
<span className="flex items-center gap-2">
<Avatar size="xs">
<AvatarImage
src={`https://api.dicebear.com/6.x/thumbs/svg?seed=${selectedPerson.name}`}
/>
<AvatarFallback>{selectedPerson.name[0]}</AvatarFallback>
</Avatar>
<span>{selectedPerson.name}</span>
</span>
)}
</ListboxTrigger>
<ListboxOptions>
{people.map((person) => (
<ListboxOption
key={person.id}
value={person}
className="flex items-center gap-2 px-3"
>
<Avatar size="xs">
<AvatarImage
src={`https://api.dicebear.com/6.x/thumbs/svg?seed=${person.name}`}
/>
<AvatarFallback>{person.name[0]}</AvatarFallback>
</Avatar>
<span>{person.name}</span>
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
);
} Previous
Label
Next
Modal