Octocat

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

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

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

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