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) return;
      const option = options[index];
      if (!option) return;

      // assuming that a `value` array means a multiselect (sorry tuples)
      const isMultiple = Array.isArray(value);

      if (isMultiple) {
        const isSelected = value.some((v) => getIsSelected(option.value, v));

        return isSelected
          ? onChange(value.filter((v) => !getIsSelected(option.value, v)) as T)
          : onChange([...value, option.value] as T);
      }

      onChange(option.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 || undefined}
      {...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}
      type="button"
      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

PropDefaultTypeDescription
value *-T | T[]The controlled value of the listbox. Can be a single value or an array for multiple selection.
onChange *-(value: T | T[]) => voidCallback fired when the value changes.
disabledfalsebooleanWhether the listbox is disabled.
invalidfalsebooleanWhether the listbox is in an invalid state.
placement"selection""selection" | PlacementThe placement of the options relative to the trigger. Use 'selection' to align with the selected option.
getIsSelected-(a: T, b: T) => booleanCustom comparison function to determine if two values are equal.
matchReferenceWidthtruebooleanWhether the options width should match the trigger width.

Listbox.Trigger

Extends the button element.

PropDefaultTypeDescription
variant"default"InputProps['variant']The visual style variant to use.
placeholder-stringThe 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.

PropDefaultTypeDescription
value *-TThe value associated with this option.
disabledfalsebooleanWhether the option is disabled.
withCheckmarktruebooleanWhether 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.

PropDefaultTypeDescription
isLoadingfalsebooleanWhen 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