Agents (llms.txt)
Octocat

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 || !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 (
    <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}
      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

  • Space or Enter: Select the focused option
  • ArrowUp / ArrowDown: Navigate between options
  • Home / End: Jump to first/last option
  • Escape: Close the listbox
  • Tab: Move focus to the next focusable element
  • Type: 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>
  );
}

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>
  );
}

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