Skip to content
UI Kit
Accent color

Combobox for React

A text field that filters a list as you type, ignores accents, highlights the match and can create new entries.

One of 7 components in Pickers and selections

1 variant

A select you can type into

A combobox is a text field with a list that filters as you type, the right control when there are too many options to scroll. This one follows the combobox pattern, the list opens on typing or with the arrow key, arrows move through matches and Enter picks one.

Filter as you type matches anywhere in the label and ignores accents and case, so cafe finds Café. It highlights the matched part of each option, shows a count of results for screen readers and offers to create a new entry when nothing fits, which suits tags, cities and customers that grow over time.

Use a combobox from about fifteen options up. Show a few suggestions before the first key, recent or popular entries help people start. Decide whether new entries are allowed and say so in the empty state, a silent no match leaves people guessing.

Variants

Filter as you type

Matches in name and description, the typed part highlighted, new rows come in one after another and the mark glides with the arrow keys. Escape closes, a second Escape clears.

Details

What Combobox brings along, read from the registry and the source.

Variants
1
Version
1.0.1 · since 1.0.0
File
components/ui/combobox.tsx
Brings along
utilskiticoninputselectpopover
npm packages
None
Languages
English and German, 5 texts of its own
Studio
Adjust in code

Props

The ComboboxProps interface from components/ui/combobox.tsx, with the comments from the code. Also takes every prop of FieldOwnProps.

  • optionsrequired
    SelectOption[]
  • valueoptional
    string
  • defaultValueoptional
    string
  • onValueChangeoptional
    (value: string) => void
  • onCreateoptional
    (text: string) => void

    Offer to create the typed text when nothing matches

  • placeholderoptional
    string
  • sizeoptional
    ControlSize
  • iconoptional
    IconName
  • nameoptional
    string
  • idoptional
    string
  • disabledoptional
    boolean
  • requiredoptional
    boolean
  • invalidoptional
    boolean
  • filteroptional
    (option: SelectOption, query: string) => boolean

    Custom matching. Default: every word of the query appears in label or description.

  • defaultQueryoptional
    string

    Open on first render with this query, for demos

  • defaultOpenoptional
    boolean

Built-in texts

Combobox writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.

Show all 5 texts
emptyEnglish No match. Try another word.German Kein Treffer. Versuch ein anderes Wort.
clearEnglish ClearGerman Leeren
toggleEnglish Show optionsGerman Optionen zeigen
resultsEnglish resultsGerman Treffer
createEnglish CreateGerman Anlegen

Accessibility

Read from the source of the component.

  • Combobox handles the keys Escape, Enter, Arrow up, Arrow down and Tab itself.
  • Sets aria-activedescendant, aria-autocomplete, aria-controls, aria-describedby, aria-expanded, aria-invalid, aria-label, aria-labelledby, aria-live and aria-selected, so screen readers announce purpose and state.
  • Uses the roles combobox, listbox and option.
  • Announces changes through a live region without moving focus.
  • Adds text for screen readers where icons and graphics stand alone.
  • The server renders the starting state in full, JavaScript only adds the interaction.

Motion

  • With reduced motion (prefers-reduced-motion) it stays still. 1 places in the code switch motion off then.
  • No animations, it stands still from the start.

Source

The file aiw add puts into your project. From then on it is yours.

Show the file, 252 lines
components/ui/combobox.tsx
'use client';

import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { cn, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { CONTROL_HEIGHT, controlBox, useControl, useShake, withField, type ControlSize, type FieldOwnProps } from '@/components/ui/input';
import { ActiveMarker, LIST_STYLE, ListboxGroups, revealOption, unfold, type SelectOption } from '@/components/ui/select';
import { Floating, panelSurface, useDismiss, useMounted } from '@/components/ui/popover';

const TEXTS = {
  en: { empty: 'No match. Try another word.', clear: 'Clear', toggle: 'Show options', results: 'results', create: 'Create' },
  de: { empty: 'Kein Treffer. Versuch ein anderes Wort.', clear: 'Leeren', toggle: 'Optionen zeigen', results: 'Treffer', create: 'Anlegen' },
};

export interface ComboboxProps extends FieldOwnProps {
  options: SelectOption[];
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string) => void;
  /** Offer to create the typed text when nothing matches */
  onCreate?: (text: string) => void;
  placeholder?: string;
  size?: ControlSize;
  icon?: IconName;
  name?: string;
  id?: string;
  disabled?: boolean;
  required?: boolean;
  invalid?: boolean;
  /** Custom matching. Default: every word of the query appears in label or description. */
  filter?: (option: SelectOption, query: string) => boolean;
  /** Open on first render with this query, for demos */
  defaultQuery?: string;
  defaultOpen?: boolean;
  labels?: Partial<(typeof TEXTS)['en']>;
  className?: string;
}

const norm = (s: string) => s.toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
const defaultFilter = (o: SelectOption, q: string) => {
  const hay = norm(`${o.label} ${o.description ?? ''}`);
  return norm(q).split(/\s+/).filter(Boolean).every((w) => hay.includes(w));
};

/** Marks the typed part in a label */
function Highlight({ text, query }: { text: string; query: string }) {
  const q = norm(query.trim());
  if (!q) return <>{text}</>;
  const i = norm(text).indexOf(q.split(/\s+/)[0]);
  if (i < 0) return <>{text}</>;
  const len = q.split(/\s+/)[0].length;
  return (
    <>
      {text.slice(0, i)}
      <mark className="rounded-[3px] bg-[color-mix(in_oklab,var(--primary)_16%,transparent)] text-inherit">{text.slice(i, i + len)}</mark>
      {text.slice(i + len)}
    </>
  );
}

function ComboboxBox({ options, value: valueProp, defaultValue, onValueChange, onCreate, placeholder, size = 'md', icon = 'search', name, id, disabled, required, invalid, filter = defaultFilter, defaultQuery, defaultOpen, labels, className }: Omit<ComboboxProps, keyof FieldOwnProps>) {
  const t = useTexts(TEXTS, labels);
  const c = useControl({ id, invalid, disabled, required });
  const [inner, setInner] = useState(defaultValue);
  const value = valueProp ?? inner;
  const selected = options.find((o) => o.value === value);
  const [query, setQuery] = useState(defaultQuery ?? selected?.label ?? '');
  const [typing, setTyping] = useState(defaultQuery !== undefined);
  const [open, setOpen] = useState(Boolean(defaultOpen));
  const [active, setActive] = useState(0);
  const mounted = useMounted();
  const boxRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const baseId = useId();
  const listId = `${baseId}-list`;

  const shown = useMemo(() => (typing && query.trim() ? options.filter((o) => filter(o, query)) : options), [options, query, typing, filter]);
  const canCreate = Boolean(onCreate && typing && query.trim() && !options.some((o) => norm(o.label) === norm(query.trim())));
  const count = shown.length + (canCreate ? 1 : 0);

  const close = useCallback(() => {
    setOpen(false);
    setTyping(false);
    setQuery((q) => (selected ? selected.label : q));
  }, [selected]);
  const refs = useRef([boxRef, listRef]).current;
  useDismiss(open, close, refs);
  useShake(boxRef, c.invalid);

  useEffect(() => {
    if (open) revealOption(active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`);
  }, [open, active, baseId, shown.length]);

  const pick = (o: SelectOption) => {
    if (o.disabled) return;
    setInner(o.value);
    onValueChange?.(o.value);
    setQuery(o.label);
    setTyping(false);
    setOpen(false);
  };
  const create = () => {
    onCreate?.(query.trim());
    setTyping(false);
    setOpen(false);
  };

  const onKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
      e.preventDefault();
      if (!open) {
        setOpen(true);
        setActive(Math.max(0, shown.findIndex((o) => o.value === value)));
        return;
      }
      setActive((a) => (a + (e.key === 'ArrowDown' ? 1 : -1) + count) % Math.max(count, 1));
    } else if (e.key === 'Enter' && open) {
      e.preventDefault();
      if (active < shown.length && shown[active]) pick(shown[active]);
      else if (canCreate) create();
    } else if (e.key === 'Escape') {
      if (open) close();
      else if (query) {
        setQuery('');
        setTyping(true);
      }
    } else if (e.key === 'Tab' && open) close();
  };

  const activeId = open && count ? (active < shown.length ? `${baseId}-opt-${active}` : `${baseId}-create`) : undefined;

  return (
    <>
      <div ref={boxRef} data-invalid={c.invalid || undefined} data-disabled={c.disabled || undefined} className={cn('group/combo relative flex w-full items-center', CONTROL_HEIGHT[size], controlBox, className)}>
        <Icon name={icon} size={16} className="pointer-events-none ms-3.5 shrink-0 text-subtle-foreground transition-colors duration-300 group-focus-within/combo:text-muted-foreground" />
        <input
          ref={inputRef}
          id={c.id}
          role="combobox"
          aria-autocomplete="list"
          aria-expanded={open}
          aria-controls={open ? listId : undefined}
          aria-activedescendant={activeId}
          aria-invalid={c['aria-invalid']}
          aria-describedby={c['aria-describedby']}
          disabled={c.disabled}
          required={c.required}
          autoComplete="off"
          spellCheck={false}
          placeholder={placeholder}
          value={query}
          onChange={(e) => {
            setQuery(e.target.value);
            setTyping(true);
            setActive(0);
            setOpen(true);
          }}
          onClick={() => setOpen(true)}
          onKeyDown={onKeyDown}
          className="h-full w-0 min-w-0 flex-1 bg-transparent ps-2.5 text-inherit outline-none placeholder:text-subtle-foreground"
        />
        {query && !c.disabled && (
          <button
            type="button"
            aria-label={t.clear}
            onClick={() => {
              setQuery('');
              setTyping(true);
              setOpen(true);
              inputRef.current?.focus();
            }}
            className="relative grid size-7 place-items-center rounded-md text-subtle-foreground transition-[background-color,color,scale] duration-200 after:absolute after:-inset-2 after:content-[''] hover:bg-accent hover:text-foreground active:scale-90 starting:scale-50 starting:opacity-0 motion-reduce:transition-none"
          >
            <Icon name="x" size={14} />
          </button>
        )}
        <button
          type="button"
          tabIndex={-1}
          aria-label={t.toggle}
          onClick={() => {
            setOpen(!open);
            inputRef.current?.focus();
          }}
          className="me-1.5 grid size-7 place-items-center rounded-md text-subtle-foreground transition-colors hover:bg-accent hover:text-foreground"
        >
          <Icon name="chevrons-up-down" size={15} className={cn('transition-[scale] duration-300 ease-[var(--ease-kit)]', open && 'scale-110')} />
        </button>
      </div>
      {name && <input type="hidden" name={name} value={value ?? ''} />}
      <span aria-live="polite" className="sr-only">
        {open && typing ? `${shown.length} ${t.results}` : ''}
      </span>
      {mounted && open && (
        <Floating anchor={boxRef} side="bottom" align="start" offset={6} matchWidth ref={listRef}>
          {(pos) => (
            <div
              id={listId}
              role="listbox"
              aria-labelledby={c.id}
              className={cn('kit-scrollbar relative max-h-72 overflow-y-auto overscroll-contain p-1.5', panelSurface, unfold(pos?.side).className)}
            >
              <style dangerouslySetInnerHTML={{ __html: LIST_STYLE }} />
              <ActiveMarker id={activeId} version={`${query}|${shown.length}`} />
              {shown.length > 0 && (
                <ListboxGroups
                  options={shown}
                  baseId={`${baseId}-opt`}
                  activeIndex={active}
                  isSelected={(o) => o.value === value}
                  onPick={pick}
                  onHover={(i) => setActive(i)}
                  render={(o) => <Highlight text={o.label} query={typing ? query : ''} />}
                />
              )}
              {canCreate && (
                <div
                  id={`${baseId}-create`}
                  role="option"
                  aria-selected={false}
                  data-active={active === shown.length || undefined}
                  onPointerMove={() => setActive(shown.length)}
                  onPointerDown={(e) => e.preventDefault()}
                  onClick={create}
                  style={{ '--i': shown.length } as React.CSSProperties}
                  className="kitfx-select-row relative flex min-h-9 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-sm text-foreground max-sm:min-h-11"
                >
                  <Icon name="plus" size={16} className="text-muted-foreground" />
                  <span className="truncate">
                    {t.create} <span className="font-medium">{query.trim()}</span>
                  </span>
                </div>
              )}
              {count === 0 && <p className="px-3 py-6 text-center text-sm text-muted-foreground">{t.empty}</p>}
            </div>
          )}
        </Floating>
      )}
    </>
  );
}

/**
 * A text field that filters a list as you type. Arrows move, Enter picks, Escape closes then clears.
 * Ignores accents and word order, highlights the match, can offer to create a new entry.
 *   <Combobox label="Country" options={countries} placeholder="Search a country" />
 */
export function Combobox({ label, hint, error, optional, labelAction, className, ...rest }: ComboboxProps) {
  const wrapped = Boolean(label || hint || error);
  return withField({ label, hint, error, optional, labelAction, id: rest.id, required: rest.required, disabled: rest.disabled, className }, <ComboboxBox {...rest} className={wrapped ? undefined : className} />);
}

Frequent questions

How do I install Combobox?

With aiw add @ai-website/combobox. The command puts components/ui/combobox.tsx into your project, the code is yours from then on. It brings along icon, input, select and popover. It needs no further npm packages.

Can I translate the texts of Combobox?

Yes. Combobox carries 5 texts of its own in English and German, such as “No match. Try another word.”, “Clear” and “Show options”. The language follows the KitProvider, single texts change through the labels prop.

Can an AI assistant build with Combobox?

Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Combobox with aiw_components and install it with aiw_add @ai-website/combobox.

Often searched

Jump to

Copied to the clipboard