SearchCombobox

Single or multi-select combobox with client filtering or async search, keyboard nav and clear.

import { SearchCombobox, ComboboxOption }from "@/components/ui"

Specification

Searches and selects one or many options, with client-side filtering or async remote search, keyboard navigation and clear.

Props (16)
Every prop this component accepts, its type, default, and how to use it.
PropTypeDefaultDescription
idstring—Explicit element id, wired to the label.
labelstring—Visible field label shown above the control.
hintstring—Helper text shown below the field when there is no error.
errorstring—Error message; renders the field in the error state.
disabledboolean—Disables the combobox.
requiredboolean—Marks the field required with a visual asterisk.
optionsT[]—Static options to filter client-side; omit when using onSearch.
onSearch(query: string, signal: AbortSignal) => Promise<T[]>—Async search callback for remote data; enables debounced remote mode with an abort signal.
multipleboolean—Allows selecting multiple options rendered as removable chips.
value*T | T[] | null—Controlled selection — an array in multiple mode, a single option or null otherwise.
onChange*(value: T | T[] | null) => void—Called with the updated selection.
getOptionValue(o: T) => stringo.valueDerives the stable value/key from an option.
getOptionLabel(o: T) => stringo.labelDerives the display label from an option.
getOptionMeta(o: T) => string | undefined—Derives optional trailing meta text shown per option.
minCharsnumber0Minimum query length before searching/filtering runs.
placeholderstring"Search…"Placeholder shown in the search input when nothing is selected.

Single & multiple

 

 

Full source
The complete component, read from src/components/ui/search-combobox.tsx.
search-combobox.tsx
"use client";

import { useState, useRef, useEffect, useMemo, useCallback, useId, forwardRef, type KeyboardEvent as ReactKeyboardEvent, type CSSProperties, type Ref, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronsUpDown, X, Loader2, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { usePopoverPosition, useMounted } from "@/hooks";
import { Field, controlBase, controlClasses } from "./field";

export interface ComboboxOption {
  value: string;
  label: string;
  [key: string]: unknown;
}

interface SearchComboboxProps<T = ComboboxOption> {
  id?: string;
  label?: string;
  hint?: string;
  error?: string;
  disabled?: boolean;
  required?: boolean;
  options?: T[];
  onSearch?: (query: string, signal: AbortSignal) => Promise<T[]>;
  multiple?: boolean;
  value: T | T[] | null;
  onChange: (value: T | T[] | null) => void;
  getOptionValue?: (o: T) => string;
  getOptionLabel?: (o: T) => string;
  getOptionMeta?: (o: T) => string | undefined;
  minChars?: number;
  placeholder?: string;
}

export function SearchCombobox<T = ComboboxOption>({
  id,
  label,
  hint,
  error,
  disabled,
  required,
  options,
  onSearch,
  multiple,
  value,
  onChange,
  getOptionValue = (o: T) => (o as ComboboxOption).value,
  getOptionLabel = (o: T) => (o as ComboboxOption).label,
  getOptionMeta,
  minChars = 0,
  placeholder = "Search…",
}: SearchComboboxProps<T>) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [active, setActive] = useState(0);
  const [remote, setRemote] = useState<T[]>([]);
  const [loading, setLoading] = useState(false);
  const [fetchError, setFetchError] = useState<string | null>(null);
  const [nonce, setNonce] = useState(0);
  const [portalContainer, setPortalContainer] = useState<HTMLElement | null>(null);
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const popoverRef = useRef<HTMLDivElement>(null);
  const position = usePopoverPosition(boxRef, open, { matchWidth: true, preferredHeight: 240 });
  const mounted = useMounted();
  const listboxId = useId();
  const isRemote = typeof onSearch === "function";
  const belowMin = query.trim().length < minChars;

  useEffect(() => {
    if (!isRemote || !open || belowMin) {
      if (isRemote) setRemote([]);
      return;
    }
    const controller = new AbortController();
    let live = true;
    const t = setTimeout(async () => {
      setLoading(true);
      setFetchError(null);
      try {
        const items = await onSearch!(query, controller.signal);
        if (live) setRemote(items);
      } catch (err: unknown) {
        if (controller.signal.aborted || (err instanceof DOMException && err.name === "AbortError")) return;
        if (live) setFetchError(err instanceof Error ? err.message : "Unknown error");
      } finally {
        if (live) setLoading(false);
      }
    }, 300);
    return () => {
      live = false;
      clearTimeout(t);
      controller.abort();
    };
  }, [isRemote, open, query, belowMin, nonce, onSearch]);

  const items = useMemo(() => {
    if (isRemote) return remote;
    const q = query.trim().toLowerCase();
    if (!q) return options || [];
    return (options || []).filter((o) => {
      const lbl = getOptionLabel(o).toLowerCase();
      const meta = getOptionMeta?.(o)?.toLowerCase();
      return lbl.includes(q) || (meta ? meta.includes(q) : false);
    });
  }, [isRemote, remote, options, query, getOptionLabel, getOptionMeta]);

  const activeIndex = items.length === 0 ? 0 : Math.min(Math.max(active, 0), items.length - 1);

  const close = useCallback(() => {
    setOpen(false);
    setQuery("");
    setPortalContainer(null);
  }, []);

  const openMenu = useCallback(() => {
    if (disabled) return;
    const dialog = boxRef.current?.closest<HTMLElement>('[role="dialog"]');
    setPortalContainer(dialog ?? document.body);
    setOpen(true);
  }, [disabled]);

  useEffect(() => {
    const h = (e: globalThis.MouseEvent) => {
      const t = e.target as Node;
      if (!boxRef.current?.contains(t) && !popoverRef.current?.contains(t)) {
        close();
      }
    };
    document.addEventListener("mousedown", h);
    return () => document.removeEventListener("mousedown", h);
  }, [close]);

  useEffect(() => {
    if (open) inputRef.current?.focus();
  }, [open]);

  const valueArray = multiple ? ((value as T[]) || []) : value ? [value as T] : [];
  const selectedValues = new Set(valueArray.map(getOptionValue));
  const hasValue = multiple ? ((value as T[]) || []).length > 0 : Boolean(value);

  const pick = useCallback(
    (o: T) => {
      if (multiple) {
        const v = getOptionValue(o);
        const arr = (value as T[]) || [];
        const has = arr.some((s) => getOptionValue(s) === v);
        onChange(has ? arr.filter((s) => getOptionValue(s) !== v) : [...arr, o]);
        setQuery("");
        inputRef.current?.focus();
      } else {
        onChange(o);
        close();
      }
    },
    [multiple, value, onChange, getOptionValue, close]
  );

  const onKeyDown = (e: ReactKeyboardEvent) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        if (!open) openMenu();
        setActive((i) => Math.min(i + 1, items.length - 1));
        break;
      case "ArrowUp":
        e.preventDefault();
        setActive((i) => Math.max(i - 1, 0));
        break;
      case "Home":
        e.preventDefault();
        setActive(0);
        break;
      case "End":
        e.preventDefault();
        setActive(items.length - 1);
        break;
      case "Enter":
        if (open && items[activeIndex]) {
          e.preventDefault();
          pick(items[activeIndex]);
        }
        break;
      case "Escape":
        close();
        break;
      case "Backspace":
        if (!query && multiple && (value as T[]).length) {
          onChange((value as T[]).slice(0, -1));
        }
        break;
    }
  };

  return (
    <Field label={label} hint={hint} error={error} htmlFor={id} required={required}>
      <div ref={boxRef} className="relative">
        <div
          onClick={openMenu}
          className={cn(
            controlBase,
            controlClasses(error, disabled),
            "flex min-h-10 flex-wrap items-center gap-1 px-2 py-1",
            !disabled && "cursor-text",
            "focus-within:border-border focus-within:ring-0 focus-visible:border-border focus-visible:ring-0",
            error && "focus-within:border-error focus-visible:border-error"
          )}
        >
          {multiple &&
            ((value as T[]) || []).map((s) => (
              <span
                key={getOptionValue(s)}
                className="inline-flex items-center gap-1 rounded bg-accent-light px-1.5 py-0.5 text-xs font-medium text-accent"
              >
                {getOptionLabel(s)}
                <button
                  type="button"
                  onClick={(e) => {
                    e.stopPropagation();
                    onChange((value as T[]).filter((x) => getOptionValue(x) !== getOptionValue(s)));
                  }}
                  aria-label={`Remove ${getOptionLabel(s)}`}
                  className="rounded-full hover:opacity-70"
                >
                  <X size={11} aria-hidden />
                </button>
              </span>
            ))}
          {!multiple && value && !query && (
            <span className="pointer-events-none absolute left-3 truncate text-sm text-text">
              {getOptionLabel(value as T)}
            </span>
          )}
          <input
            id={id}
            ref={inputRef}
            role="combobox"
            aria-expanded={open}
            aria-haspopup="listbox"
            aria-controls={open ? listboxId : undefined}
            aria-activedescendant={open && items.length > 0 ? `${listboxId}-opt-${activeIndex}` : undefined}
            aria-required={required}
            value={query}
            disabled={disabled}
            placeholder={hasValue ? "" : placeholder}
            onFocus={openMenu}
            onChange={(e) => {
              setQuery(e.target.value);
              setActive(0);
              setOpen(true);
            }}
            onKeyDown={onKeyDown}
            style={{ outline: "none" }}
            className="min-w-16 flex-1 cursor-text bg-transparent px-1 py-0.5 text-sm text-text placeholder:text-text-tertiary"
          />
          <span className="ml-auto flex items-center gap-1 pl-1">
            {loading && <Loader2 size={13} className="animate-spin text-text-tertiary" aria-hidden />}
            {hasValue && !disabled && (
              <button
                type="button"
                onClick={(e) => {
                  e.stopPropagation();
                  onChange(multiple ? [] : null);
                }}
                aria-label="Clear"
                className="rounded p-0.5 text-text-tertiary hover:text-text"
              >
                <X size={14} aria-hidden />
              </button>
            )}
            <ChevronsUpDown size={14} className="text-text-tertiary" aria-hidden />
          </span>
        </div>
        {open &&
          position &&
          mounted &&
          portalContainer &&
          createPortal(
            <ComboboxDropdown
              ref={popoverRef}
              listboxId={listboxId}
              multiple={multiple}
              items={items}
              loading={loading}
              fetchError={fetchError}
              belowMin={belowMin}
              minChars={minChars}
              query={query}
              active={activeIndex}
              selectedValues={selectedValues}
              position={position}
              getOptionValue={getOptionValue}
              getOptionLabel={getOptionLabel}
              getOptionMeta={getOptionMeta}
              onPick={pick}
              onSetActive={setActive}
              onRetry={() => setNonce((n) => n + 1)}
            />,
            portalContainer
          )}
      </div>
    </Field>
  );
}

interface ComboboxDropdownProps<T> {
  listboxId: string;
  multiple?: boolean;
  items: T[];
  loading: boolean;
  fetchError: string | null;
  belowMin: boolean;
  minChars: number;
  query: string;
  active: number;
  selectedValues: Set<string>;
  position: { style: CSSProperties };
  getOptionValue: (o: T) => string;
  getOptionLabel: (o: T) => string;
  getOptionMeta?: (o: T) => string | undefined;
  onPick: (o: T) => void;
  onSetActive: (i: number) => void;
  onRetry: () => void;
}

const ComboboxDropdown = forwardRef(function ComboboxDropdown<T>(
  {
    listboxId,
    multiple,
    items,
    loading,
    fetchError,
    belowMin,
    minChars,
    query,
    active,
    selectedValues,
    position,
    getOptionValue,
    getOptionLabel,
    getOptionMeta,
    onPick,
    onSetActive,
    onRetry,
  }: ComboboxDropdownProps<T>,
  ref: Ref<HTMLDivElement>
) {
  const activeRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    activeRef.current?.scrollIntoView({ block: "nearest" });
  }, [active]);

  return (
    <div
      ref={ref}
      id={listboxId}
      role="listbox"
      aria-label="Options"
      className="z-40 max-h-60 overflow-auto rounded-lg border border-border bg-surface py-1 shadow-lg"
      style={position.style}
    >
      {loading ? (
        <p className="flex items-center gap-2 px-3 py-4 text-sm text-text-secondary">
          <Loader2 size={14} className="animate-spin" aria-hidden /> Searching…
        </p>
      ) : fetchError ? (
        <div className="px-3 py-4 text-center">
          <p className="text-sm text-error">{fetchError}</p>
          <button
            type="button"
            onClick={onRetry}
            className="mt-2 inline-flex items-center gap-1.5 rounded text-xs font-medium text-accent underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
          >
            <RotateCw size={12} aria-hidden /> Try again
          </button>
        </div>
      ) : belowMin ? (
        <p className="px-3 py-4 text-center text-sm text-text-secondary">
          Type at least {minChars} character{minChars !== 1 && "s"} to search.
        </p>
      ) : items.length === 0 ? (
        <div className="px-3 py-4 text-center">
          <p className="text-sm text-text">{query ? `No match for "${query}"` : "Nothing to choose from"}</p>
          {query && <p className="mt-0.5 text-xs text-text-secondary">Check the spelling, or clear the search.</p>}
        </div>
      ) : (
        items.map((o, i) => {
          const v = getOptionValue(o);
          const on = selectedValues.has(v);
          const meta = getOptionMeta?.(o);
          const isActive = i === active;
          return (
            <div
              key={v}
              ref={isActive ? activeRef : undefined}
              id={`${listboxId}-opt-${i}`}
              role="option"
              aria-selected={on}
              onMouseEnter={() => onSetActive(i)}
              onMouseDown={(e) => e.preventDefault()}
              onClick={() => onPick(o)}
              className={cn(
                "flex cursor-pointer items-center gap-2 px-3 py-1.5 text-left text-sm text-text transition-colors hover:bg-bg-hover",
                isActive && "bg-bg-secondary"
              )}
            >
              {multiple && (
                <span
                  className={cn(
                    "flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border",
                    on ? "border-accent bg-accent" : "border-border bg-transparent"
                  )}
                >
                  {on && <Check size={11} strokeWidth={3} className="text-on-accent" />}
                </span>
              )}
              <span className="flex-1 truncate">{getOptionLabel(o)}</span>
              {meta && <span className="text-xs tabular-nums text-text-tertiary">{meta}</span>}
              {!multiple && on && <Check size={14} className="shrink-0 text-accent" aria-hidden />}
            </div>
          );
        })
      )}
    </div>
  );
}) as <T>(props: ComboboxDropdownProps<T> & { ref?: Ref<HTMLDivElement> }) => ReactElement;

Dependencies

To use SearchCombobox you also need the following. Copy these into your project alongside it.

Recommended folder structure

project structure
src/components/ui/
  ├─ field.tsx
  └─ search-combobox.tsx
src/hooks/
  ├─ use-mounted.ts
  └─ use-popover-position.ts
src/lib/
  └─ utils.ts

Dependency files

These supporting files aren’t components — copy each one into the matching path above.

src/components/ui/field.tsx
field.tsx
"use client";

import { type ReactNode } from "react";
import { cn } from "@/lib/utils";

interface FieldCounter {
  text: string;
  warn?: boolean;
}

interface FieldProps {
  label?: string;
  hint?: string;
  error?: string;
  required?: boolean;
  htmlFor?: string;
  counter?: FieldCounter;

  messageId?: string;
  children: ReactNode;
}

export function Field({
  label,
  hint,
  error,
  required,
  htmlFor,
  counter,
  messageId,
  children,
}: FieldProps) {
  const message = error || hint;
  return (
    <div className="flex w-full flex-col gap-1.5">
      {label && (
        <label
          htmlFor={htmlFor}
          className="flex items-center gap-1 text-[13px] font-medium text-text"
        >
          {label}
          {required && (
            <span className="text-error" aria-hidden>
              *
            </span>
          )}
        </label>
      )}
      {children}
      <div className="flex items-start justify-between gap-3">
        <p
          id={messageId}
          className={cn("text-xs leading-snug", error ? "text-error" : "text-text-secondary")}
        >
          {message || "\u00A0"}
        </p>
        {counter && (
          <span
            className={cn(
              "shrink-0 text-xs tabular-nums",
              counter.warn ? "text-error" : "text-text-tertiary"
            )}
          >
            {counter.text}
          </span>
        )}
      </div>
    </div>
  );
}

export const controlBase = cn(
  "w-full rounded-lg border text-sm bg-surface text-text transition-colors",
  "placeholder:text-text-tertiary",
  "focus:outline-none focus-visible:outline-none focus-visible:border-accent",
  "disabled:cursor-not-allowed disabled:bg-bg-secondary disabled:opacity-60",
  "read-only:bg-bg-secondary"
);

export function controlClasses(error?: string, disabled?: boolean, readOnly?: boolean) {
  return cn(
    error ? "border-error focus-visible:border-error" : "border-border",
    readOnly && "read-only:bg-bg-secondary",
    disabled && "text-text-tertiary"
  );
}
src/hooks/use-mounted.ts
use-mounted.ts
"use client";

import { useState, useEffect } from "react";

/**
 * Returns true once the component has mounted on the client.
 * Useful for portals and anything that needs `document` to exist.
 */
export function useMounted(): boolean {
  const [mounted, setMounted] = useState(false);
  useEffect(() => setMounted(true), []);
  return mounted;
}
src/hooks/use-popover-position.ts
use-popover-position.ts
"use client";

import { useState, useEffect, useCallback, type RefObject, type CSSProperties } from "react";

interface PopoverPositionOptions {
  align?: "start" | "center" | "end";
  gap?: number;
  viewportPadding?: number;
  preferredHeight?: number;
  matchWidth?: boolean;
  minWidth?: number;
}

interface PopoverPosition {
  placement: "top" | "bottom";
  style: CSSProperties;
}

/**
 * Compute a fixed-position popover style anchored to a ref element.
 * Opens downward by default; flips upward when there isn't room below
 * AND there's more room above. Clamps horizontally so nothing renders
 * past the viewport edge. Re-measures on scroll/resize while open.
 */
export function usePopoverPosition(
  anchorRef: RefObject<HTMLElement | null>,
  open: boolean,
  options: PopoverPositionOptions = {}
): PopoverPosition | null {
  const {
    align = "start",
    gap = 4,
    viewportPadding = 8,
    preferredHeight = 320,
    matchWidth = false,
    minWidth,
  } = options;

  const [position, setPosition] = useState<PopoverPosition | null>(null);

  const measure = useCallback(() => {
    const anchor = anchorRef.current;
    if (!anchor) return;
    const rect = anchor.getBoundingClientRect();
    const vw = window.innerWidth;
    const vh = window.innerHeight;
    const spaceBelow = vh - rect.bottom - viewportPadding;
    const spaceAbove = rect.top - viewportPadding;
    const placement =
      spaceBelow < Math.min(preferredHeight, 160) && spaceAbove > spaceBelow
        ? "top"
        : "bottom";
    const maxHeight = Math.max(
      120,
      Math.min(preferredHeight, placement === "top" ? spaceAbove : spaceBelow)
    );
    const width = matchWidth ? rect.width : minWidth;
    let left = align === "end" && width ? rect.right - width : rect.left;
    if (align === "center" && width)
      left = rect.left + rect.width / 2 - width / 2;
    left = width
      ? Math.min(Math.max(left, viewportPadding), vw - width - viewportPadding)
      : Math.min(Math.max(left, viewportPadding), vw - viewportPadding);

    setPosition({
      placement,
      style: {
        position: "fixed",
        top: placement === "top" ? undefined : rect.bottom + gap,
        bottom: placement === "top" ? vh - rect.top + gap : undefined,
        left,
        width: matchWidth ? rect.width : undefined,
        minWidth: !matchWidth ? minWidth : undefined,
        maxHeight,
      },
    });
  }, [anchorRef, align, gap, viewportPadding, preferredHeight, matchWidth, minWidth]);

  useEffect(() => {
    if (!open) {
      setPosition(null);
      return;
    }
    measure();
    window.addEventListener("scroll", measure, true);
    window.addEventListener("resize", measure);
    return () => {
      window.removeEventListener("scroll", measure, true);
      window.removeEventListener("resize", measure);
    };
  }, [open, measure]);

  return position;
}
src/lib/utils.ts
utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

/**
 * Merge class names with clsx semantics + Tailwind conflict resolution.
 * Prefer this in all components.
 */
export function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

/**
 * Concatenate class names, filtering out falsy values.
 * @deprecated Prefer {@link cn} — it also resolves Tailwind class conflicts.
 */
export function cx(...args: (string | false | null | undefined)[]): string {
  return args.filter(Boolean).join(" ");
}

/**
 * Focus ring style helper.
 * @deprecated Focus rings are now handled globally via the
 * `:focus-visible` rule in `globals.css` and the `focus-visible:*`
 * utilities. This remains only for backwards compatibility.
 */
export function ring(color: string = "var(--color-accent)") {
  return { outlineColor: color };
}