Combobox
Type to filter options, then pick one like a select. Large catalogs where search beats scrolling.
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
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| autoHighlight | boolean | false | `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>
| Clase | Propósito |
|---|---|
combobox | root |
combobox__content | element |
combobox__empty | element |
combobox__group | element |
combobox__group-label | element |
combobox__group-list | element |
combobox__input | element |
combobox__input-icon | element |
combobox__input-wrapper | element |
combobox__item | element |
combobox__item--disabled | element |
combobox__item--highlighted | element |
combobox__item--selected | element |
combobox__list | element |
combobox__separator | element |
combobox__trigger | element |
combobox__trigger--disabled | element |
combobox__trigger--open | element |
combobox__trigger-icon | element |
combobox__trigger-text | element |
combobox__trigger-text--placeholder | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #e5e5e5 | border |
| all | fg | #525252 | muted.foreground |
| all | border | none | (unparsed) |
| all | hover-border | #171717 | border.focus |
| all | focused-border | #f84131 | destructive |
| all | hover-bg | #f5f5f5 | accent |
| all | hover-fg | #171717 | accent.foreground |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
border-color | var(--duration-150) | var(--easing-in-out) |
box-shadow | var(--duration-150) | var(--easing-in-out) |
transform | var(--duration-200) | var(--easing-in-out) |
background-color | var(--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
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
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-visibleon.combobox__trigger:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Code:focus-visibleon.combobox__item:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.combobox__trigger[aria-invalid="true"]— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.combobox__trigger[aria-invalid="true"]:focus-visible, .combobox__trigger[aria-invalid="true"].combobox__trigger--open— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.combobox__trigger--open .combobox__trigger-icon— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.combobox__input::placeholder— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.combobox__item:hover, .combobox__item--highlighted— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.combobox__trigger:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)
Tras pegar: Create component → nombre Atom / Combobox → Publish.