ATOM
Components (registry)Forms

Combobox

Type to filter options, then pick one like a select. Large catalogs where search beats scrolling.

Preview

Editorial

<!-- F12c editorial — non-derivable only. Review: Karen. -->

## Ejemplos

Searchable country picker:

```tsx import { Combobox, ComboboxInput, ComboboxContent, ComboboxItem, } from '@/components/molecules/Combobox';

<Combobox value={v} onValueChange={setV}> <ComboboxInput placeholder="Search country" /> <ComboboxContent> <ComboboxItem value="mx">Mexico</ComboboxItem> <ComboboxItem value="us">United States</ComboboxItem> </ComboboxContent> </Combobox> ```

## Accesibilidad

- Pair with `Field` + label; keep arrow keys / Enter / Escape on the input — do not replace it with a non-textbox. - Portal content can mismatch on first paint in SSR — open the list on the client when needed.

## Cuándo no usar

- Fewer than ~5 fixed options with no search need → `Select` / radio list. - Free-form text without a closed list → `Input` / `Textarea`.

## Criterio de uso

- Usa Combobox cuando la lista es larga o desconocida y escribir es mas rapido que buscar; por debajo de ~7 opciones, `Select` es mejor. - `autoHighlight` acelera al usuario que escribe y confirma con Enter; desactivalo si una seleccion accidental es costosa. - Muestra siempre un estado vacio util: "sin resultados para X" con la opcion de limpiar.

## Gotchas

- Tiene DOS estados controlados independientes (`value` y `open`): confundirlos deja el panel abierto tras elegir o el valor congelado. - El filtrado es responsabilidad del consumidor: el componente no decide que coincide.

Uso

import { Combobox, ComboboxInput, ComboboxContent, ComboboxItem } from '@/components/molecules/Combobox';

<Combobox value={v} onValueChange={setV}>
  <ComboboxInput placeholder="Search country" />
  <ComboboxContent>
    <ComboboxItem value="mx">Mexico</ComboboxItem>
    <ComboboxItem value="us">United States</ComboboxItem>
  </ComboboxContent>
</Combobox>

Props

PropTipoDefaultRango / opcionesWhatHow
autoHighlightbooleanfalse`true` / `false`Whether the first option is highlighted as the user types.true for short lists (<20); false when accidental Enter would pick a wrong option.

Gotchas

  • react

    Compose Combobox + ComboboxInput + ComboboxContent/Item; controlled mode needs value/onValueChange and often open/onOpenChange.

  • ssr

    Portal content can mismatch on first paint in Next — mount the open list on the client.

  • a11y

    Pair with Field label; keep keyboard arrows/Enter/Escape working — do not replace the input with a non-textbox.

Anatomía CSS

<div class="combobox">
  <span class="combobox__content"></span>
  <span class="combobox__empty"></span>
  <span class="combobox__group"></span>
  <span class="combobox__group-label"></span>
  <span class="combobox__group-list"></span>
  <span class="combobox__input"></span>
  <span class="combobox__input-icon"></span>
  <span class="combobox__input-wrapper"></span>
  <span class="combobox__item"></span>
  <span class="combobox__item--disabled"></span>
  <span class="combobox__item--highlighted"></span>
  <span class="combobox__item--selected"></span>
  <span class="combobox__list"></span>
  <span class="combobox__separator"></span>
  <span class="combobox__trigger"></span>
  <span class="combobox__trigger--disabled"></span>
  <span class="combobox__trigger--open"></span>
  <span class="combobox__trigger-icon"></span>
  <span class="combobox__trigger-text"></span>
  <span class="combobox__trigger-text--placeholder"></span>
</div>
ClasePropósito
comboboxroot
combobox__contentelement
combobox__emptyelement
combobox__groupelement
combobox__group-labelelement
combobox__group-listelement
combobox__inputelement
combobox__input-iconelement
combobox__input-wrapperelement
combobox__itemelement
combobox__item--disabledelement
combobox__item--highlightedelement
combobox__item--selectedelement
combobox__listelement
combobox__separatorelement
combobox__triggerelement
combobox__trigger--disabledelement
combobox__trigger--openelement
combobox__trigger-iconelement
combobox__trigger-textelement
combobox__trigger-text--placeholderelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#e5e5e5border
allfg#525252muted.foreground
allbordernone(unparsed)
allhover-border#171717border.focus
allfocused-border#f84131destructive
allhover-bg#f5f5f5accent
allhover-fg#171717accent.foreground

Animaciones

PropiedadDuraciónEasing
border-colorvar(--duration-150)var(--easing-in-out)
box-shadowvar(--duration-150)var(--easing-in-out)
transformvar(--duration-200)var(--easing-in-out)
background-colorvar(--duration-100)var(--easing-in-out)
@keyframes combobox-in

CSS mínimo funcional

Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).

/* -------------------------------------------------------------------------
   Combobox

   Searchable select. Trigger + floating listbox with filter input.
   Composable: Combobox > Trigger + Content > Input + List > Item

   Parts: .combobox, .combobox__trigger, .combobox__content,
          .combobox__input, .combobox__list, .combobox__item,
          .combobox__group, .combobox__group-label, .combobox__empty
   ------------------------------------------------------------------------- */

/* ---- Root ---- */

.combobox {
  position: relative;
  display: inline-flex;
  font-family: 'inter tight', -apple-system, blinkmacsystemfont, 'segoe ui', roboto, helvetica, arial, sans-serif, ui-sans-serif, system-ui, sans-serif;
}

/* ---- Trigger ---- */

.combobox__trigger {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  height: 40px;
  padding: 0 12px;
  border: 1px solid #d4d4d4;
  border-radius: 8px;
  background-color: #fafafa;
  color: #0a0a0a;
  font-family: inherit;
  font-size: 12.8px;
  font-weight: 400;
  line-height: 1;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition:
    border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
    box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1);
}

.combobox__trigger:hover {
  border-color: #171717;
}

.combobox__trigger:focus-visible {
  border-color: #171717;
  box-shadow: 0 0 0 2px var(--focus-ring-color);
  outline: none;
}

.combobox__trigger--open {
  border-color: #171717;
  box-shadow: 0 0 0 2px var(--focus-ring-color);
}

.combobox__trigger--disabled {
  background-color: #f5f5f5;
  border-color: #f5f5f5;
  color: #525252;
  cursor: not-allowed;
  pointer-events: none;
}

.combobox__trigger[aria-invalid="true"] {
  border-color: #f84131;
  box-shadow: 0 0 2px 0 var(--focus-ring-error-color);
}

.combobox__trigger[aria-invalid="true"]:focus-visible,
.combobox__trigger[aria-invalid="true"].combobox__trigger--open {
  border-color: #f84131;
  box-shadow: 0 0 0 2px var(--focus-ring-error-color);
}

.combobox__trigger-text {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.combobox__trigger-text--placeholder {
  color: #525252;
}

.combobox__trigger-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  color: #525252;
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}

.combobox__trigger--open .combobox__trigger-icon {
  transform: rotate(180deg);
}

/* ---- Content (floating panel) ---- */

.combobox__content {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 20;
  width: 100%;
  min-width: 200px;
  overflow: hidden;
  border: 1px solid #e5e5e5;
  border-radius: 12px;
  background-color: #ffffff;
  color: #0a0a0a;
  box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
  transform-origin: top;
  animation: combobox-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}

@keyframes combobox-in {
  from {
    opacity: 0;
    transform: scale(0.96) translateY(-4px);
  }
  to {
    opacity: 1;
    transform: scale(1) translateY(0);
  }
}

/* ---- Search input inside content ---- */

.combobox__input-wrapper {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  border-bottom: 1px solid #e5e5e5;
}

.combobox__input-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  width: 1em;
  height: 1em;
  color: #525252;
}

.combobox__input {
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  color: #0a0a0a;
  font-family: inherit;
  font-size: 12.8px;
  line-height: 1;
  outline: none;
  padding: 0;
}

.combobox__input::placeholder {
  color: #525252;
}

/* ---- List ---- */

.combobox__list {
  max-height: 240px;
  overflow-y: auto;
  padding: 4px;
}

/* ---- Empty state ---- */

.combobox__empty {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 8px;
  color: #525252;
  font-size: 12.8px;
}

/* ---- Group ---- */

.combobox__group-list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.combobox__group-label {
  padding: 8px 8px 4px;
  font-size: 10.24px;
  font-weight: 500;
  color: #525252;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* ---- Item ---- */

.combobox__item {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  padding: 8px;
  border: none;
  border-radius: 4px;
  background: none;
  color: #0a0a0a;
  font-family: inherit;
  font-size: 12.8px;
  line-height: 1;
  text-align: left;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background-color 100ms cubic-bezier(0.4, 0, 0.2, 1);
}

.combobox__item:hover,
.combobox__item--highlighted {
  background-color: #f5f5f5;
  color: #171717;
}

.combobox__item--selected {
  font-weight: 500;
}

.combobox__item--disabled {
  color: #525252;
  cursor: not-allowed;
  pointer-events: none;
}

.combobox__item:focus-visible {
  outline: 2px solid var(--focus-ring-color);
  outline-offset: -2px;
  border-radius: 4px;
}

/* ---- Separator ---- */

.combobox__separator {
  height: 1px;
  margin: 4px calc(-1 * 4px);
  background-color: #e5e5e5;
}

/* ---- Reduced motion ---- */

@media (prefers-reduced-motion: reduce) {
  .combobox__trigger,
  .combobox__trigger-icon,
  .combobox__item {
    transition-duration: 0ms;
  }

  .combobox__content {
    animation-duration: 0ms;
  }
}

Codigo fuente

components-react
components/atoms/Combobox.tsx
import {
  type ReactNode,
  type KeyboardEvent,
  type InputHTMLAttributes,
  useState,
  useRef,
  useEffect,
  useCallback,
  createContext,
  useContext,
  forwardRef,
} from 'react';

function cn(...classes: (string | false | undefined | null)[]) {
  return classes.filter(Boolean).join(' ');
}

/* ---- Icons ---- */

const ChevronDown = () => (
  <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <path d="M6 9l6 6 6-6" />
  </svg>
);

const SearchIcon = () => (
  <svg width="100%" height="100%" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
    <circle cx="7" cy="7" r="4" />
    <path d="M10 10l3.5 3.5" />
  </svg>
);

/* ---- Context ---- */

type ComboboxContextValue = {
  open: boolean;
  setOpen: (v: boolean) => void;
  value: string;
  onSelect: (value: string) => void;
  search: string;
  setSearch: (v: string) => void;
  highlightedIndex: number;
  setHighlightedIndex: (i: number) => void;
  registerItem: (value: string, label: string) => void;
  unregisterItem: (value: string) => void;
  visibleItems: string[];
  filteredCount: number;
  autoHighlight: boolean;
};

const ComboboxContext = createContext<ComboboxContextValue | null>(null);

function useCombobox() {
  const ctx = useContext(ComboboxContext);
  if (!ctx) throw new Error('Combobox compound components must be used within <Combobox>');
  return ctx;
}

/* ---- Root ---- */

export type ComboboxProps = {
  value?: string;
  onValueChange?: (value: string) => void;
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
  autoHighlight?: boolean;
  children: ReactNode;
  className?: string;
};

export function Combobox({
  value: controlledValue,
  onValueChange,
  open: controlledOpen,
  onOpenChange,
  autoHighlight = false,
  children,
  className,
}: ComboboxProps) {
  const [internalValue, setInternalValue] = useState('');
  const [internalOpen, setInternalOpen] = useState(false);
  const [search, setSearch] = useState('');
  const [highlightedIndex, setHighlightedIndex] = useState(-1);
  const [registeredItems, setRegisteredItems] = useState<{ value: string; label: string }[]>([]);
  const rootRef = useRef<HTMLDivElement>(null);

  const value = controlledValue ?? internalValue;
  const open = controlledOpen ?? internalOpen;

  const setOpen = useCallback(
    (v: boolean) => {
      onOpenChange ? onOpenChange(v) : setInternalOpen(v);
      if (!v) {
        setSearch('');
        setHighlightedIndex(-1);
      }
    },
    [onOpenChange],
  );

  const onSelect = useCallback(
    (v: string) => {
      const next = v === value ? '' : v;
      onValueChange ? onValueChange(next) : setInternalValue(next);
      setOpen(false);
    },
    [value, onValueChange, setOpen],
  );

  const registerItem = useCallback((v: string, label: string) => {
    setRegisteredItems((prev) =>
      prev.some((item) => item.value === v) ? prev : [...prev, { value: v, label }],
    );
  }, []);

  const unregisterItem = useCallback((v: string) => {
    setRegisteredItems((prev) => prev.filter((item) => item.value !== v));
  }, []);

  const filteredCount = registeredItems.filter(
    (item) => !search || item.label.toLowerCase().includes(search.toLowerCase()),
  ).length;

  // Close on outside click
  useEffect(() => {
    if (!open) return;
    const handler = (e: MouseEvent) => {
      if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [open, setOpen]);

  // Close on Escape
  useEffect(() => {
    if (!open) return;
    const handler = (e: globalThis.KeyboardEvent) => {
      if (e.key === 'Escape') setOpen(false);
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [open, setOpen]);

  return (
    <ComboboxContext.Provider
      value={{
        open,
        setOpen,
        value,
        onSelect,
        search,
        setSearch,
        highlightedIndex,
        setHighlightedIndex,
        registerItem,
        unregisterItem,
        visibleItems: registeredItems.map((item) => item.value),
        filteredCount,
        autoHighlight,
      }}
    >
      <div ref={rootRef} className={cn('combobox', className)}>
        {children}
      </div>
    </ComboboxContext.Provider>
  );
}

/* ---- Trigger ---- */

export type ComboboxTriggerProps = {
  placeholder?: string;
  disabled?: boolean;
  invalid?: boolean;
  children?: ReactNode;
  className?: string;
};

export function ComboboxTrigger({
  placeholder = 'Select...',
  disabled = false,
  invalid = false,
  children,
  className,
}: ComboboxTriggerProps) {
  const { open, setOpen, value } = useCombobox();

  const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'ArrowDown' || e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      setOpen(true);
    }
  };

  return (
    <button
      type="button"
      role="combobox"
      aria-expanded={open}
      aria-haspopup="listbox"
      aria-invalid={invalid || undefined}
      disabled={disabled}
      className={cn(
        'combobox__trigger',
        open && 'combobox__trigger--open',
        disabled && 'combobox__trigger--disabled',
        className,
      )}
      onClick={() => setOpen(!open)}
      onKeyDown={handleKeyDown}
    >
      <span
        className={cn(
          'combobox__trigger-text',
          !value && !children && 'combobox__trigger-text--placeholder',
        )}
      >
        {children || (value ? value : placeholder)}
      </span>
      <span className="combobox__trigger-icon">
        <ChevronDown />
      </span>
    </button>
  );
}

/* ---- Content ---- */

export function ComboboxContent({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  const { open } = useCombobox();
  if (!open) return null;
  return <div className={cn('combobox__content', className)}>{children}</div>;
}

/* ---- Input ---- */

export type ComboboxInputProps = {
  className?: string;
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type' | 'className'>;

export const ComboboxInput = forwardRef<HTMLInputElement, ComboboxInputProps>(
  ({ className, placeholder = 'Search...', ...props }, ref) => {
    const { search, setSearch, highlightedIndex, setHighlightedIndex, visibleItems, onSelect, autoHighlight } =
      useCombobox();
    const internalRef = useRef<HTMLInputElement>(null);
    const inputRef = (ref as React.RefObject<HTMLInputElement>) || internalRef;

    // Auto-focus when content opens
    useEffect(() => {
      inputRef.current?.focus();
    }, [inputRef]);

    const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
      if (e.key === 'ArrowDown') {
        e.preventDefault();
        setHighlightedIndex(
          highlightedIndex < visibleItems.length - 1 ? highlightedIndex + 1 : 0,
        );
      } else if (e.key === 'ArrowUp') {
        e.preventDefault();
        setHighlightedIndex(
          highlightedIndex > 0 ? highlightedIndex - 1 : visibleItems.length - 1,
        );
      } else if (e.key === 'Enter' && highlightedIndex >= 0) {
        e.preventDefault();
        const item = visibleItems[highlightedIndex];
        if (item) onSelect(item);
      }
    };

    return (
      <div className={cn('combobox__input-wrapper', className)}>
        <span className="combobox__input-icon">
          <SearchIcon />
        </span>
        <input
          ref={inputRef}
          type="text"
          className="combobox__input"
          value={search}
          onChange={(e) => {
            setSearch(e.target.value);
            setHighlightedIndex(autoHighlight ? 0 : -1);
          }}
          placeholder={placeholder}
          aria-autocomplete="list"
          onKeyDown={handleKeyDown}
          {...props}
        />
      </div>
    );
  },
);

ComboboxInput.displayName = 'ComboboxInput';

/* ---- List ---- */

export function ComboboxList({
  children,
  className,
}: {
  children: ReactNode;
  className?: string;
}) {
  return (
    <ul role="listbox" className={cn('combobox__list', className)}>
      {children}
    </ul>
  );
}

/* ---- Empty ---- */

export function ComboboxEmpty({
  children = 'No results found.',
  className,
}: {
  children?: ReactNode;
  className?: string;
}) {
  const { filteredCount } = useCombobox();
  if (filteredCount > 0) return null;
  return <div className={cn('combobox__empty', className)}>{children}</div>;
}

/* ---- Group ---- */

export function ComboboxGroup({
  label,
  children,
  className,
}: {
  label?: string;
  children: ReactNode;
  className?: string;
}) {
  return (
    <li role="group" aria-label={label} className={className}>
      {label && <div className="combobox__group-label">{label}</div>}
      <ul role="group" className="combobox__group-list">{children}</ul>
    </li>
  );
}

/* ---- Item ---- */

export type ComboboxItemProps = {
  value: string;
  label?: string;
  disabled?: boolean;
  children?: ReactNode;
  className?: string;
};

export function ComboboxItem({ value: itemValue, label: labelProp, disabled = false, children, className }: ComboboxItemProps) {
  const { value, onSelect, search, highlightedIndex, visibleItems, registerItem, unregisterItem } =
    useCombobox();

  const isSelected = value === itemValue;
  const index = visibleItems.indexOf(itemValue);
  const isHighlighted = index === highlightedIndex;

  const searchLabel = labelProp || (typeof children === 'string' ? children : itemValue);

  // Register/unregister
  useEffect(() => {
    registerItem(itemValue, searchLabel);
    return () => unregisterItem(itemValue);
  }, [itemValue, searchLabel, registerItem, unregisterItem]);

  const matchesSearch =
    !search || searchLabel.toLowerCase().includes(search.toLowerCase());

  if (!matchesSearch) return null;

  return (
    <li
      role="option"
      aria-selected={isSelected}
      aria-disabled={disabled || undefined}
      className={cn(
        'combobox__item',
        isSelected && 'combobox__item--selected',
        isHighlighted && 'combobox__item--highlighted',
        disabled && 'combobox__item--disabled',
        className,
      )}
      onClick={() => {
        if (!disabled) onSelect(itemValue);
      }}
    >
      {children || itemValue}
    </li>
  );
}

/* ---- Separator ---- */

export function ComboboxSeparator({ className }: { className?: string }) {
  return <li role="separator" className={cn('combobox__separator', className)} />;
}

Webflow

Webflowcombobox

Pega en el Designer como application/json, luego convierte a Component (Atom / Combobox) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.

Setup del sitio (una vez)

Custom Code → Head:

<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css">
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">

Unsupported (no silencioso)

  • :focus-visible on .combobox__trigger:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • :focus-visible on .combobox__item:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .combobox__trigger[aria-invalid="true"]compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .combobox__trigger[aria-invalid="true"]:focus-visible, .combobox__trigger[aria-invalid="true"].combobox__trigger--opencompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .combobox__trigger--open .combobox__trigger-iconcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .combobox__input::placeholdercompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .combobox__item:hover, .combobox__item--highlightedcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .combobox__trigger:focus-visiblecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)

Tras pegar: Create component → nombre Atom / Combobox → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categoriaforms
Lectura...
Visitas...
Ayuda?Slack

Componente

Combobox

Source

components-react / cssDisponible via MCP: atom_uikit_source("combobox")
Abrir en Storybook