Combobox
A searchable select that filters as you type and highlights the matched substring.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-combobox.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/core/Combobox.tsx
import { useCallback, useId, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { Check, ChevronDown, Search, X } from 'lucide-react';
import { cn } from '@/lib/cn';
import { useClickOutside, useEscape, usePresence } from '@/components/niceui/ui/motion/hooks';
export type ComboboxOption = { value: string; label: string; meta?: string };
export type ComboboxProps = {
options: ComboboxOption[];
value: string | null;
onChange: (value: string | null) => void;
placeholder?: string;
label?: string;
emptyText?: string;
};
export function Highlight({ text, query }: { text: string; query: string }) {
const q = query.trim();
const i = q ? text.toLowerCase().indexOf(q.toLowerCase()) : -1;
if (i < 0) return <>{text}</>;
return (
<>
{text.slice(0, i)}
<mark className="rounded-[3px] bg-brand-500/20 px-px text-brand-200">{text.slice(i, i + q.length)}</mark>
{text.slice(i + q.length)}
</>
);
}
export function Combobox({ options, value, onChange, placeholder = 'Search…', label, emptyText = 'No results' }: ComboboxProps) {
const selected = options.find((o) => o.value === value) ?? null;
const [query, setQuery] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const root = useRef<HTMLDivElement>(null);
const input = useRef<HTMLInputElement>(null);
const id = useId();
const { mounted, visible } = usePresence(open, 160);
const close = useCallback(() => {
setOpen(false);
setQuery('');
}, []);
useEscape(open, close);
useClickOutside([root], open, close);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return q ? options.filter((o) => o.label.toLowerCase().includes(q) || o.meta?.toLowerCase().includes(q)) : options;
}, [options, query]);
const pick = (o: ComboboxOption) => {
onChange(o.value);
close();
input.current?.blur();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open) return setOpen(true);
setActive((a) => Math.min(a + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((a) => Math.max(a - 1, 0));
} else if (e.key === 'Enter' && open && filtered[active]) {
e.preventDefault();
pick(filtered[active]);
}
};
return (
<div ref={root} className="relative w-full">
{label && (
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-ink-200">
{label}
</label>
)}
<div
className={cn(
'flex h-10 items-center gap-2 rounded-lg bg-ink-900 px-3 ring-1 ring-inset transition-all',
open ? 'ring-brand-500 shadow-[0_0_0_4px_rgba(56,189,248,0.15)]' : 'ring-white/10 hover:ring-white/20',
)}
>
<Search className={cn('h-4 w-4 shrink-0 transition-colors', open ? 'text-brand-300' : 'text-ink-500')} />
<input
ref={input}
id={id}
role="combobox"
aria-expanded={open}
aria-autocomplete="list"
autoComplete="off"
value={open ? query : selected?.label ?? ''}
placeholder={open && selected ? selected.label : placeholder}
onFocus={() => setOpen(true)}
onBlur={close}
onChange={(e) => {
setQuery(e.target.value);
setActive(0);
setOpen(true);
}}
onKeyDown={onKey}
className="h-full min-w-0 flex-1 bg-transparent text-sm text-ink-50 outline-none placeholder:text-ink-500"
/>
{selected && !open ? (
<button
type="button"
aria-label="Clear"
onClick={() => onChange(null)}
className="focus-ring flex h-6 w-6 animate-scale-in items-center justify-center rounded-md text-ink-400 hover:bg-white/5 hover:text-ink-100"
>
<X className="h-3.5 w-3.5" />
</button>
) : (
<ChevronDown className={cn('h-4 w-4 text-ink-400 transition-transform duration-300 ease-spring', open && 'rotate-180')} />
)}
</div>
{mounted && (
<div
onPointerDown={(e) => e.preventDefault()}
className={cn(
'absolute left-0 right-0 top-full z-40 mt-2 origin-top overflow-hidden rounded-xl bg-ink-850 shadow-pop',
visible ? 'scale-100 opacity-100 transition-all duration-300 ease-spring' : 'scale-95 opacity-0 transition-all duration-150',
)}
>
<ul role="listbox" className="max-h-60 overflow-auto p-1">
{filtered.map((o, i) => (
<li
key={o.value}
role="option"
aria-selected={o.value === value}
onPointerDown={(e) => e.preventDefault()}
onPointerMove={() => setActive(i)}
onClick={() => pick(o)}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-lg px-2.5 py-2 text-sm transition-colors',
i === active ? 'bg-white/[0.06] text-ink-50' : 'text-ink-300',
)}
>
<span className="flex-1 truncate">
<Highlight text={o.label} query={query} />
</span>
{o.meta && (
<span className="font-mono text-xs text-ink-500">
<Highlight text={o.meta} query={query} />
</span>
)}
<Check className={cn('h-4 w-4 text-brand-300 transition-all', o.value === value ? 'scale-100 opacity-100' : 'scale-50 opacity-0')} />
</li>
))}
{filtered.length === 0 && (
<li className="animate-fade-in px-3 py-6 text-center text-sm text-ink-400">
{emptyText} for “<span className="text-ink-200">{query}</span>”
</li>
)}
</ul>
<div className="flex items-center justify-between border-t border-white/5 px-3 py-2 text-[11px] text-ink-500">
<span>{filtered.length} results</span>
<span>↑↓ navigate · ↵ select</span>
</div>
</div>
)}
</div>
);
}
const PACKAGES: ComboboxOption[] = [
{ value: 'react', label: 'React', meta: '18.3.1' },
{ value: 'next', label: 'Next.js', meta: '14.2.5' },
{ value: 'remix', label: 'Remix', meta: '2.11.0' },
{ value: 'astro', label: 'Astro', meta: '4.13.1' },
{ value: 'svelte', label: 'SvelteKit', meta: '2.5.20' },
{ value: 'nuxt', label: 'Nuxt', meta: '3.12.4' },
{ value: 'solid', label: 'SolidStart', meta: '1.0.6' },
{ value: 'qwik', label: 'Qwik City', meta: '1.7.3' },
{ value: 'vite', label: 'Vite', meta: '5.4.0' },
{ value: 'gatsby', label: 'Gatsby', meta: '5.13.7' },
];
export function ComboboxDemo() {
const [value, setValue] = useState<string | null>(null);
return (
<div className="mx-auto w-full max-w-xs self-start">
<Combobox label="Framework" options={PACKAGES} value={value} onChange={setValue} placeholder="Search frameworks…" />
<p className="mt-3 text-xs text-ink-500">Try typing “st” or “1.7”.</p>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}