← Componentes

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

Tamaño
Clearable
<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.

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

inválido

carga (abre el panel)

sin resultados (busca algo)

<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

PropTipoDefault
optionsComboboxOption[] | ComboboxGroup[]
multiplebooleanfalse
value / defaultValuestring | string[]
onValueChange(value: string | string[]) => void
sizexs | sm | md | lgmd
clearablebooleanfalse
loadingbooleanfalse
onOpenChange(open: boolean) => void
placeholder / searchPlaceholder / emptyMessagestring
aria-invalidbooleanfalse
disabledbooleanfalse

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.