Maksud UI
Components

Listbox

An accessible listbox component with single and multiple selection, keyboard navigation, search, and customizable icons.

API
'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-react

Make 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.

'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

PropDescription
optionsArray of options to display.
valueSelected value(s). Use string for single selection, string[] for multiple.
onChangeCallback when selection changes.
multipleEnable multiple selection mode.
searchableEnable search input to filter options.
searchPlaceholderPlaceholder text for the search input.
emptyMessageMessage displayed when no options match the search or list is empty.
iconIcon component displayed next to selected items.
classNameAdditional CSS classes for the container.

ListboxOption

PropDescription
labelDisplay text for the option.
valueUnique identifier for the option.
disabledDisable the option from being selected.
descriptionOptional description text shown below the label.

Keyboard Navigation

The Listbox component supports full keyboard navigation:

  • - Navigate through options
  • Enter / Space - Select focused option
  • Home / 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