Components
Listbox
An accessible listbox component with single and multiple selection, keyboard navigation, search, and customizable icons.
'use client';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const options: ListboxOption[] = [
{ label: 'React', value: 'react' },
{ label: 'Vue', value: 'vue' },
{ label: 'Angular', value: 'angular' },
{ label: 'Svelte', value: 'svelte' },
{ label: 'Ember', value: 'ember' },
];
export function ListboxDemo() {
const [value, setValue] = React.useState<string>('react');
return (
<div className='w-full max-w-sm'>
<Listbox options={options} value={value} onChange={setValue} className='h-[250px]' />
</div>
);
}Installation
CLI
npx shadcn@latest add "https://maksud.dev/r/listbox"Manual
Install the following dependencies:
npm install lucide-reactMake sure you have the following components installed:
Copy and paste the following code into your project.
'use client';
import { cn } from '@/components/lib/utils';
import { ScrollArea } from '@radix-ui/react-scroll-area';
import { Check } from 'lucide-react';
import * as React from 'react';
export interface ListboxOption {
label: string;
value: string;
disabled?: boolean;
description?: string;
}
interface BaseListboxProps {
options: ListboxOption[];
className?: string;
emptyMessage?: string;
searchable?: boolean;
searchPlaceholder?: string;
icon?: React.ComponentType<{ className?: string }>;
}
interface SingleSelectProps extends BaseListboxProps {
multiple?: false;
value?: string;
onChange: (value: string) => void;
}
interface MultipleSelectProps extends BaseListboxProps {
multiple: true;
value?: string[];
onChange: (value: string[]) => void;
}
type ListboxProps = SingleSelectProps | MultipleSelectProps;
function Listbox({
options,
value,
onChange,
multiple = false,
className,
emptyMessage = 'No options available',
searchable = false,
searchPlaceholder = 'Search...',
icon: Icon = Check,
}: ListboxProps) {
const [searchQuery, setSearchQuery] = React.useState('');
const [focusedIndex, setFocusedIndex] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
const optionRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const filteredOptions = React.useMemo(() => {
if (!searchQuery) return options;
const query = searchQuery.toLowerCase();
return options.filter(
(option) =>
option.label.toLowerCase().includes(query) ||
option.description?.toLowerCase().includes(query)
);
}, [options, searchQuery]);
const isSelected = React.useCallback(
(optionValue: string) => {
if (multiple) {
return (value as string[] | undefined)?.includes(optionValue) ?? false;
}
return value === optionValue;
},
[value, multiple]
);
const handleSelect = React.useCallback(
(optionValue: string) => {
if (multiple) {
const currentValue = (value as string[] | undefined) ?? [];
const newValue = currentValue.includes(optionValue)
? currentValue.filter((v) => v !== optionValue)
: [...currentValue, optionValue];
(onChange as (value: string[]) => void)(newValue);
} else {
(onChange as (value: string) => void)(optionValue);
}
},
[value, onChange, multiple]
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent) => {
const enabledOptions = filteredOptions.filter((opt) => !opt.disabled);
if (enabledOptions.length === 0) return;
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
setFocusedIndex((prev) => {
const newIndex = prev < enabledOptions.length - 1 ? prev + 1 : 0;
optionRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' });
return newIndex;
});
break;
}
case 'ArrowUp': {
e.preventDefault();
setFocusedIndex((prev) => {
const newIndex = prev > 0 ? prev - 1 : enabledOptions.length - 1;
optionRefs.current[newIndex]?.scrollIntoView({ block: 'nearest' });
return newIndex;
});
break;
}
case 'Enter':
case ' ': {
e.preventDefault();
const focusedOption = enabledOptions[focusedIndex];
if (focusedOption) {
handleSelect(focusedOption.value);
}
break;
}
case 'Home': {
e.preventDefault();
setFocusedIndex(0);
optionRefs.current[0]?.scrollIntoView({ block: 'nearest' });
break;
}
case 'End': {
e.preventDefault();
setFocusedIndex(enabledOptions.length - 1);
optionRefs.current[enabledOptions.length - 1]?.scrollIntoView({
block: 'nearest',
});
break;
}
}
},
[filteredOptions, focusedIndex, handleSelect]
);
React.useEffect(() => {
if (focusedIndex >= filteredOptions.length) {
setFocusedIndex(Math.max(0, filteredOptions.length - 1));
}
}, [filteredOptions.length, focusedIndex]);
return (
<div className={cn('flex flex-col gap-2', className)}>
{searchable && (
<div className='px-3 pt-2'>
<input
type='text'
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className='w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50'
/>
</div>
)}
<ScrollArea className='h-full'>
<div
ref={listRef}
role='listbox'
aria-multiselectable={multiple}
tabIndex={0}
onKeyDown={handleKeyDown}
className='p-1 outline-none'
>
{filteredOptions.length === 0 ? (
<div className='py-8 text-center text-muted-foreground text-sm'>{emptyMessage}</div>
) : (
filteredOptions.map((option, index) => {
const selected = isSelected(option.value);
const focused = index === focusedIndex;
return (
<div
key={option.value}
ref={(el) => {
optionRefs.current[index] = el;
}}
role='option'
aria-selected={selected}
aria-disabled={option.disabled}
tabIndex={-1}
className={cn(
'relative flex cursor-pointer items-center gap-3 rounded-md px-3 py-2 text-sm outline-none transition-colors',
'hover:bg-accent focus:bg-accent',
selected && 'bg-accent/50',
focused && 'bg-accent/50',
option.disabled && 'cursor-not-allowed opacity-50 hover:bg-transparent'
)}
onClick={() => {
if (!option.disabled) {
handleSelect(option.value);
setFocusedIndex(index);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!option.disabled) {
handleSelect(option.value);
}
}
}}
onMouseEnter={() => !option.disabled && setFocusedIndex(index)}
>
<div className='flex flex-1 flex-col gap-0.5'>
<span className='leading-none'>{option.label}</span>
{option.description && (
<span className='text-muted-foreground text-xs'>{option.description}</span>
)}
</div>
<div className='h-4 w-4 shrink-0'>
{selected && Icon && <Icon className='h-4 w-4 text-primary' />}
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</div>
);
}
export { Listbox };Layout
Import the component and use it in your application.
import { Listbox } from "@/components/ui/listbox";
const options = [
{ label: "Option 1", value: "1" },
{ label: "Option 2", value: "2" },
{ label: "Option 3", value: "3" },
];
export default function Example() {
const [value, setValue] = React.useState("");
return (
<Listbox
options={options}
value={value}
onChange={setValue}
/>
);
}Examples
Single Selection
'use client';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const teamOptions: ListboxOption[] = [
{ label: 'Engineering', value: 'engineering', description: 'Build and maintain products' },
{ label: 'Design', value: 'design', description: 'Create beautiful interfaces' },
{ label: 'Marketing', value: 'marketing', description: 'Grow the business' },
{ label: 'Sales', value: 'sales', description: 'Close deals and partnerships' },
{ label: 'Product', value: 'product', description: 'Define the roadmap' },
{ label: 'HR', value: 'hr', description: 'People and culture', disabled: true },
];
export function ListboxSingleDemo() {
const [selectedTeam, setSelectedTeam] = React.useState<string>('engineering');
return (
<div className='w-full max-w-sm space-y-4'>
<Listbox
options={teamOptions}
value={selectedTeam}
onChange={setSelectedTeam}
className='h-[300px]'
/>
<div className='text-sm'>
<span className='font-medium'>Selected: </span>
<span className='text-muted-foreground'>
{teamOptions.find((t) => t.value === selectedTeam)?.label || 'None'}
</span>
</div>
</div>
);
}Select a single option from the list.
Multiple Selection
'use client';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const projectOptions: ListboxOption[] = [
{ label: 'Website Redesign', value: 'website-redesign' },
{ label: 'Mobile App', value: 'mobile-app' },
{ label: 'API Development', value: 'api-development' },
{ label: 'Database Migration', value: 'database-migration' },
{ label: 'Security Audit', value: 'security-audit' },
{ label: 'Performance Optimization', value: 'performance-optimization' },
];
export function ListboxMultipleDemo() {
const [selectedProjects, setSelectedProjects] = React.useState<string[]>(['mobile-app']);
return (
<div className='w-full max-w-sm space-y-4'>
<Listbox
multiple
options={projectOptions}
value={selectedProjects}
onChange={setSelectedProjects}
className='h-[300px]'
/>
<div className='text-sm'>
<span className='font-medium'>Selected ({selectedProjects.length}): </span>
<span className='text-muted-foreground'>
{selectedProjects.length > 0 ? selectedProjects.join(', ') : 'None'}
</span>
</div>
</div>
);
}Select multiple options using the multiple prop.
With Search
'use client';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const teamOptions: ListboxOption[] = [
{ label: 'Engineering', value: 'engineering', description: 'Build and maintain products' },
{ label: 'Design', value: 'design', description: 'Create beautiful interfaces' },
{ label: 'Marketing', value: 'marketing', description: 'Grow the business' },
{ label: 'Sales', value: 'sales', description: 'Close deals and partnerships' },
{ label: 'Product', value: 'product', description: 'Define the roadmap' },
{
label: 'Customer Support',
value: 'customer-support',
description: 'Help our customers succeed',
},
{ label: 'Finance', value: 'finance', description: 'Manage the numbers' },
];
export function ListboxSearchableDemo() {
const [selectedTeam, setSelectedTeam] = React.useState<string>('design');
return (
<div className='w-full max-w-sm'>
<Listbox
searchable
options={teamOptions}
value={selectedTeam}
onChange={setSelectedTeam}
className='h-[350px]'
/>
</div>
);
}Enable search functionality with the searchable prop to filter options.
With Descriptions
'use client';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const statusOptions: ListboxOption[] = [
{ label: 'To Do', value: 'todo', description: 'Task has not been started' },
{ label: 'In Progress', value: 'in-progress', description: 'Currently being worked on' },
{ label: 'Review', value: 'review', description: 'Ready for code review' },
{ label: 'Testing', value: 'testing', description: 'Being tested by QA' },
{ label: 'Done', value: 'done', description: 'Task is completed' },
];
export function ListboxWithDescriptionsDemo() {
const [status, setStatus] = React.useState<string>('in-progress');
return (
<div className='w-full max-w-sm'>
<Listbox options={statusOptions} value={status} onChange={setStatus} className='h-[300px]' />
</div>
);
}Display additional information for each option using the description field.
Custom Icon
'use client';
import { Heart } from 'lucide-react';
import * as React from 'react';
import { Listbox, type ListboxOption } from '@/components/ui/listbox';
const projectOptions: ListboxOption[] = [
{ label: 'Website Redesign', value: 'website-redesign' },
{ label: 'Mobile App', value: 'mobile-app' },
{ label: 'API Development', value: 'api-development' },
{ label: 'Database Migration', value: 'database-migration' },
{ label: 'Security Audit', value: 'security-audit' },
];
export function ListboxCustomIconDemo() {
const [selectedFavorites, setSelectedFavorites] = React.useState<string[]>(['mobile-app']);
return (
<div className='w-full max-w-sm space-y-4'>
<Listbox
multiple
searchable
options={projectOptions}
value={selectedFavorites}
onChange={setSelectedFavorites}
icon={Heart}
className='h-[300px]'
/>
<div className='text-sm'>
<span className='font-medium'>Favorites: </span>
<span className='text-muted-foreground'>{selectedFavorites.length}</span>
</div>
</div>
);
}Customize the selection indicator icon with the icon prop.
API Reference
Listbox
| Prop | Description |
|---|---|
options | Array of options to display. |
value | Selected value(s). Use string for single selection, string[] for multiple. |
onChange | Callback when selection changes. |
multiple | Enable multiple selection mode. |
searchable | Enable search input to filter options. |
searchPlaceholder | Placeholder text for the search input. |
emptyMessage | Message displayed when no options match the search or list is empty. |
icon | Icon component displayed next to selected items. |
className | Additional CSS classes for the container. |
ListboxOption
| Prop | Description |
|---|---|
label | Display text for the option. |
value | Unique identifier for the option. |
disabled | Disable the option from being selected. |
description | Optional description text shown below the label. |
Keyboard Navigation
The Listbox component supports full keyboard navigation:
↑↓- Navigate through optionsEnter/Space- Select focused optionHome/End- Jump to first/last option- Focus is automatically managed and visible
Accessibility
- Uses proper ARIA roles (
listbox,option) for screen reader support - Full keyboard navigation with arrow keys, Enter, Space, Home, and End
- Proper focus management with visible indicators
- Selected and disabled states are announced to assistive technologies
- Search input is properly labeled for accessibility