Combobox
Un <select> real no puede buscar entre cien opciones ni mostrar más de una selección — Combobox es el trigger de Input con un listbox propio debajo: buscador con acentos normalizados, teclado completo (flechas, Enter, Esc), y selección simple o múltiple con la misma pieza.
Playground
- Obsidian
- Marfil
- Ámbar
- Ébano
- Zafiro
- Ceniza
- Cobalto
<Combobox clearable options={plantillas} value={value} onValueChange={setValue} placeholder="Selecciona una plantilla…" />Múltiple, con grupos
multiple cambia el trigger de texto a chips removibles — cada uno con su propia × enfocable por teclado, no un div decorativo adentro de un botón (eso rompe la navegación por Tab). options acepta una lista plana o agrupada; el buscador filtra adentro de cada grupo y oculta los que se quedan vacíos.
Fundamentos
- Input
- Checkbox
- Badge
Overlays
- Dialog
- Alert Dialog
- Collapsible
Datos
- Card
- Calendar
- Chart
const groups: ComboboxGroup[] = [
{ label: "Fundamentos", options: [{ value: "button", label: "Button" }, ...] },
{ label: "Overlays", options: [{ value: "dialog", label: "Dialog" }, ...] },
];
const [value, setValue] = useState<string[]>(["button"]);
<Combobox multiple options={groups} value={value} onValueChange={setValue} clearable />
{/* multiple=true cambia el tipo de value/onValueChange a string[] — un
discriminated union, no un any suelto que revienta en runtime */}Buscador
El filtro ignora acentos y mayúsculas — escribir ambar encuentra Ámbar en el playground de arriba, sin que tengas que teclear el acento a mano. Las flechas mueven la opción activa (aria-activedescendant, no un focus() real por opción — el foco de teclado nunca sale del campo de búsqueda), Enter selecciona, Esc cierra.
Estados
aria-invalid tiñe el borde en danger — mismo mecanismo que Input y Checkbox, no un prop error aparte. loading es tu estado: Combobox no pide datos solo, tú decides cuándo disparar la carga con onOpenChange.
disabled
- Sin resultados.
inválido
- Sin resultados.
carga (abre el panel)
- Sin resultados.
sin resultados (busca algo)
- Obsidian
<Combobox options={plantillas} aria-invalid="true" placeholder="Selecciona una plantilla…" />
const [options, setOptions] = useState<ComboboxOption[]>([]);
const [loading, setLoading] = useState(false);
<Combobox
options={options}
loading={loading}
onOpenChange={(open) => {
if (!open) return;
setLoading(true);
fetchOptions().then((data) => {
setOptions(data);
setLoading(false);
});
}}
/>
{/* loading es tu estado, no el de Combobox — onOpenChange dispara la
carga justo al abrir, no antes */}Código
"use client";
import * as React from "react";
import { cn } from "./lib/cn";
import { EASE } from "./lib/motion";
import { useDismiss } from "./lib/use-dismiss";
function ChevronDownIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
);
}
function CheckIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<path d="M5 12.5l4.5 4.5L19 7.5" />
</svg>
);
}
function XIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" className={className} aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
}
function SearchIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" className={className} aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6.5" />
<path d="M20 20l-4.35-4.35" />
</svg>
);
}
function Spinner({ className }: { className?: string }) {
return (
<svg className={cn("animate-spin", className)} viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
);
}
export interface ComboboxOption {
value: string;
label: string;
disabled?: boolean;
}
export interface ComboboxGroup {
label: string;
options: ComboboxOption[];
}
function isGroups(options: ComboboxOption[] | ComboboxGroup[]): options is ComboboxGroup[] {
return options.length > 0 && "options" in options[0];
}
// sin acentos y sin mayúsculas — "cafe" tiene que encontrar "Café", el
// buscador no debería exigir que tipees el acento a mano
function normalize(str: string) {
return str.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
}
type ComboboxSize = "xs" | "sm" | "md" | "lg";
const sizeClass: Record<ComboboxSize, string> = {
xs: "min-h-7 px-2 text-xs gap-1",
sm: "min-h-8 px-3 text-sm gap-1.5",
md: "min-h-10 px-3 text-sm gap-1.5",
lg: "min-h-12 px-4 text-base gap-2",
};
type ComboboxBaseProps = {
options: ComboboxOption[] | ComboboxGroup[];
placeholder?: string;
searchPlaceholder?: string;
emptyMessage?: string;
size?: ComboboxSize;
disabled?: boolean;
loading?: boolean;
/** muestra una × para vaciar la selección de un click, sin abrir el panel */
clearable?: boolean;
/** se dispara al abrir o cerrar el panel — para pedir opciones remotas justo cuando hacen falta, no antes */
onOpenChange?: (open: boolean) => void;
className?: string;
id?: string;
"aria-invalid"?: boolean;
};
export type ComboboxProps = ComboboxBaseProps &
(
| { multiple?: false; value?: string; defaultValue?: string; onValueChange?: (value: string) => void }
| { multiple: true; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void }
);
export function Combobox(props: ComboboxProps) {
const {
options,
placeholder = "Selecciona una opción…",
searchPlaceholder = "Buscar…",
emptyMessage = "Sin resultados.",
size = "md",
disabled = false,
loading = false,
clearable = false,
onOpenChange,
className,
id,
multiple = false,
"aria-invalid": ariaInvalid,
} = props;
const isControlled = props.value !== undefined;
const [uncontrolledValue, setUncontrolledValue] = React.useState<string | string[]>(
props.defaultValue ?? (multiple ? [] : ""),
);
const rawValue = isControlled ? props.value! : uncontrolledValue;
const selected: string[] = multiple ? (rawValue as string[]) : rawValue ? [rawValue as string] : [];
const [open, setOpenState] = React.useState(false);
const [query, setQuery] = React.useState("");
const [activeIndex, setActiveIndex] = React.useState(0);
const containerRef = React.useRef<HTMLDivElement>(null);
const searchRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLUListElement>(null);
const listboxId = React.useId();
const setOpen = React.useCallback(
(next: boolean | ((prev: boolean) => boolean)) => {
setOpenState((prev) => {
const resolved = typeof next === "function" ? next(prev) : next;
if (resolved !== prev) onOpenChange?.(resolved);
return resolved;
});
},
[onOpenChange],
);
const close = React.useCallback(() => setOpen(false), [setOpen]);
useDismiss(open, close, containerRef);
React.useEffect(() => {
if (open) {
requestAnimationFrame(() => searchRef.current?.focus());
setQuery("");
setActiveIndex(0);
}
}, [open]);
const grouped = isGroups(options);
const groups: ComboboxGroup[] = grouped ? options : [{ label: "", options }];
const filteredGroups = React.useMemo(() => {
const q = normalize(query);
return groups
.map((group) => ({ ...group, options: group.options.filter((opt) => normalize(opt.label).includes(q)) }))
.filter((group) => group.options.length > 0);
}, [groups, query]);
const flatVisible = React.useMemo(() => filteredGroups.flatMap((g) => g.options), [filteredGroups]);
const commit = (nextValue: string | string[]) => {
if (!isControlled) setUncontrolledValue(nextValue);
(props.onValueChange as ((v: string | string[]) => void) | undefined)?.(nextValue);
};
const selectOption = (option: ComboboxOption) => {
if (option.disabled) return;
if (multiple) {
const next = selected.includes(option.value) ? selected.filter((v) => v !== option.value) : [...selected, option.value];
commit(next);
} else {
commit(option.value);
setOpen(false);
}
};
const clear = (e: React.MouseEvent) => {
e.stopPropagation();
commit(multiple ? [] : "");
};
const removeChip = (e: React.MouseEvent, value: string) => {
e.stopPropagation();
commit(selected.filter((v) => v !== value));
};
const labelFor = (value: string) => groups.flatMap((g) => g.options).find((o) => o.value === value)?.label ?? value;
const onSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setActiveIndex((i) => Math.min(i + 1, flatVisible.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
const option = flatVisible[activeIndex];
if (option) selectOption(option);
}
};
React.useEffect(() => {
if (!open) return;
const activeEl = listRef.current?.querySelector<HTMLElement>(`[data-index="${activeIndex}"]`);
activeEl?.scrollIntoView({ block: "nearest" });
}, [activeIndex, open]);
const activeOption = flatVisible[activeIndex];
const hasSelection = selected.length > 0;
return (
// w-full, no shrink-to-fit — mismo criterio que Input: el ancho lo
// decide el layout (o el className del consumidor), no el largo del
// texto seleccionado en este momento
<div ref={containerRef} className={cn("relative w-full", className)}>
{/* div, no button: los chips y la × son controles independientes con
su propio foco — HTML no permite anidar elementos interactivos
dentro de un <button>, así que el trigger entero no puede serlo */}
<div
id={id}
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-invalid={ariaInvalid}
aria-disabled={disabled}
tabIndex={disabled ? -1 : 0}
onClick={() => !disabled && setOpen((o) => !o)}
onKeyDown={(e) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
}}
className={cn(
"flex w-full flex-wrap items-center rounded-md border border-border bg-background text-left text-foreground outline-none transition-colors",
EASE,
disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer",
"focus-visible:ring-2 focus-visible:ring-primary",
"aria-invalid:border-danger aria-invalid:focus-visible:ring-danger",
sizeClass[size],
)}
>
{multiple && hasSelection ? (
selected.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 rounded-[2px] bg-muted py-0.5 pl-2 pr-1 text-xs font-medium text-foreground"
>
{labelFor(v)}
<button
type="button"
aria-label={`Quitar ${labelFor(v)}`}
onClick={(e) => removeChip(e, v)}
className="rounded-sm p-0.5 text-muted-foreground outline-none transition-colors hover:bg-border hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary"
>
<XIcon className="h-2.5 w-2.5" />
</button>
</span>
))
) : (
<span className={cn("flex-1 truncate", !hasSelection && "text-muted-foreground")}>
{hasSelection ? labelFor(selected[0]) : placeholder}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1">
{clearable && hasSelection && !disabled && (
<button
type="button"
aria-label="Vaciar selección"
onClick={clear}
className="rounded-sm p-0.5 text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
<ChevronDownIcon className={cn("h-4 w-4 text-muted-foreground transition-transform duration-200", EASE, open && "rotate-180")} />
</span>
</div>
<div
role="presentation"
className={cn(
"absolute left-0 top-full z-20 mt-2 w-full min-w-[14rem] origin-top overflow-hidden rounded-md border border-border bg-background shadow-none transition-all duration-150",
EASE,
open ? "pointer-events-auto scale-100 opacity-100" : "pointer-events-none scale-95 opacity-0",
)}
>
<div className="flex items-center gap-2 border-b border-border px-3">
<SearchIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
ref={searchRef}
role="searchbox"
aria-autocomplete="list"
aria-controls={listboxId}
aria-activedescendant={activeOption ? `${listboxId}-${activeOption.value}` : undefined}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActiveIndex(0);
}}
onKeyDown={onSearchKeyDown}
placeholder={searchPlaceholder}
className="h-10 min-w-0 flex-1 bg-transparent text-sm text-foreground outline-none placeholder:text-muted-foreground"
/>
</div>
<ul id={listboxId} ref={listRef} role="listbox" aria-multiselectable={multiple} className="max-h-60 overflow-y-auto p-1">
{loading ? (
<li className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Spinner className="h-4 w-4" />
Cargando…
</li>
) : flatVisible.length === 0 ? (
<li className="px-3 py-6 text-center text-sm text-muted-foreground">{emptyMessage}</li>
) : (
filteredGroups.map((group, gi) => (
<li key={group.label || gi} role="presentation">
{group.label && (
<p className="px-2 pb-1 pt-2 text-xs font-medium text-muted-foreground first:pt-1">{group.label}</p>
)}
<ul role="presentation">
{group.options.map((option) => {
const index = flatVisible.indexOf(option);
const isSelected = selected.includes(option.value);
return (
<li
key={option.value}
id={`${listboxId}-${option.value}`}
data-index={index}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => selectOption(option)}
className={cn(
"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors duration-100",
option.disabled && "pointer-events-none opacity-40",
index === activeIndex ? "bg-muted text-foreground" : "text-foreground",
)}
>
<span className={cn("flex h-3.5 w-3.5 shrink-0 items-center justify-center text-primary", !isSelected && "opacity-0")}>
<CheckIcon className="h-3.5 w-3.5" />
</span>
<span className="flex-1 truncate">{option.label}</span>
</li>
);
})}
</ul>
</li>
))
)}
</ul>
</div>
</div>
);
}Código real de packages/ui/src/combobox.tsx. El panel no usa portal — mismo criterio que Calendar y los dropdowns de Button Group: se posiciona relativo a su propio contenedor, no escapa al <body>.
Uso
import { Combobox, type ComboboxOption } from "./components/ui/combobox";
const plantillas: ComboboxOption[] = [
{ value: "obsidian", label: "Obsidian" },
{ value: "marfil", label: "Marfil" },
];
const [value, setValue] = useState("");
<Combobox options={plantillas} value={value} onValueChange={setValue} placeholder="Selecciona una plantilla…" />API
| Prop | Tipo | Default |
|---|---|---|
| options | ComboboxOption[] | ComboboxGroup[] | — |
| multiple | boolean | false |
| value / defaultValue | string | string[] | — |
| onValueChange | (value: string | string[]) => void | — |
| size | xs | sm | md | lg | md |
| clearable | boolean | false |
| loading | boolean | false |
| onOpenChange | (open: boolean) => void | — |
| placeholder / searchPlaceholder / emptyMessage | string | — |
| aria-invalid | boolean | false |
| disabled | boolean | false |
multiple es un discriminated union con value/onValueChange — TypeScript fuerza string[] cuando está en true y string cuando no, sin que tengas que castear nada a mano.