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.
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Explicit element id, wired to the label. |
| label | string | — | Visible field label shown above the control. |
| hint | string | — | Helper text shown below the field when there is no error. |
| error | string | — | Error message; renders the field in the error state. |
| disabled | boolean | — | Disables the combobox. |
| required | boolean | — | Marks the field required with a visual asterisk. |
| options | T[] | — | 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. |
| multiple | boolean | — | 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) => string | o.value | Derives the stable value/key from an option. |
| getOptionLabel | (o: T) => string | o.label | Derives the display label from an option. |
| getOptionMeta | (o: T) => string | undefined | — | Derives optional trailing meta text shown per option. |
| minChars | number | 0 | Minimum query length before searching/filtering runs. |
| placeholder | string | "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 };
}