Building a Robust React Chip Input with Autocomplete in React

Build an accessible, high-performance React chip input with autocomplete: patterns, keyboard support, async data, TypeScript code, and production tips.

ASOasis
9 min read
Building a Robust React Chip Input with Autocomplete in React

Image used for representation purposes only.

Overview

A chip input autocomplete (also called a “tags input” or “tokenized input”) lets users enter multiple values as discrete chips while getting typeahead suggestions. It’s common in email recipients, skill pickers, label editors, and permission UIs. In this article, we’ll design, build, and production‑harden a React chip input with accessibility, great keyboard support, and async data.

UX goals and behaviors

A solid chip input should:

  • Show selected values as removable chips before the text cursor.
  • Offer suggestions in a popover as the user types.
  • Support keyboard: Arrow keys, Enter, Tab, Backspace, Escape.
  • Prevent duplicates and optionally allow custom values (freeSolo).
  • Handle paste of comma/newline‑separated values.
  • Work with IMEs (composition), screen readers, and RTL locales.
  • Be controllable (value passed via props) and stateless by default.

Accessibility model (ARIA)

Use the ARIA combobox pattern controlling a listbox. For multi‑select with chips:

  • Input element: role=“combobox”, aria-expanded, aria-controls, aria-autocomplete=“list”, and aria-activedescendant when an option is highlighted.
  • Listbox: role=“listbox”; options: role=“option” with id.
  • Chips: render as inline list (role=“list” with li) or as buttons; each chip’s remove control is a button with aria-label like “Remove {label}”.
  • Announce changes: use aria-live=“polite” for feedback such as “Added {label}”.
  • Focus management: keep focus in the input; do not trap focus in chips.

State model

You’ll typically manage four pieces of state:

  • value: Option[] (selected chips)
  • inputValue: string (current query)
  • isOpen: boolean (whether suggestions are visible)
  • highlightedIndex: number | null (active suggestion)

For performance, derive filtered options from options and inputValue, memoized.

TypeScript option shape

export type Option = {
  id: string | number;
  label: string;
};

Minimal, accessible React implementation

The component below is a concise but production‑ready starting point. It supports keyboard navigation, freeSolo, duplicates control, paste parsing, and IME.

import React, {useEffect, useId, useMemo, useRef, useState} from 'react';

export type Option = { id: string | number; label: string };

export type ChipInputProps = {
  options: Option[];
  value: Option[];
  onChange: (next: Option[]) => void;
  placeholder?: string;
  allowCustom?: boolean; // allow creating chips not in options
  maxChips?: number;
  disabled?: boolean;
  getOptionLabel?: (o: Option) => string;
  isOptionDisabled?: (o: Option) => boolean;
  noOptionsText?: string;
  ariaLabel?: string;
  name?: string; // for hidden input submission
};

export function ChipInputAutocomplete({
  options,
  value,
  onChange,
  placeholder = 'Add…',
  allowCustom = false,
  maxChips,
  disabled,
  getOptionLabel = (o) => o.label,
  isOptionDisabled = () => false,
  noOptionsText = 'No matches',
  ariaLabel = 'Chip input',
  name
}: ChipInputProps) {
  const listboxId = useId();
  const inputId = useId();
  const [inputValue, setInputValue] = useState('');
  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [isComposing, setIsComposing] = useState(false);
  const liveRef = useRef<HTMLDivElement | null>(null);
  const inputRef = useRef<HTMLInputElement | null>(null);

  // Dedupe map for quick lookup
  const selectedIds = useMemo(() => new Set(value.map(v => String(v.id))), [value]);

  const filtered = useMemo(() => {
    const q = inputValue.trim().toLowerCase();
    const base = options.filter(o => !selectedIds.has(String(o.id)) && !isOptionDisabled(o));
    if (!q) return base.slice(0, 50);
    return base
      .filter(o => getOptionLabel(o).toLowerCase().includes(q))
      .slice(0, 50);
  }, [options, inputValue, selectedIds, getOptionLabel, isOptionDisabled]);

  useEffect(() => {
    setActiveIndex(filtered.length ? 0 : null);
  }, [inputValue, filtered.length]);

  const announce = (msg: string) => {
    if (liveRef.current) {
      liveRef.current.textContent = msg;
      // Clear message to avoid repetition not being read
      setTimeout(() => { if (liveRef.current) liveRef.current.textContent = ''; }, 100);
    }
  };

  const canAddMore = maxChips == null || value.length < maxChips;

  const commit = (opt: Option | string) => {
    if (!canAddMore) return;
    if (typeof opt === 'string') {
      if (!allowCustom) return;
      const trimmed = opt.trim();
      if (!trimmed) return;
      // avoid duplicate custom labels
      if (value.some(v => getOptionLabel(v).toLowerCase() === trimmed.toLowerCase())) return;
      const created: Option = { id: trimmed, label: trimmed };
      onChange([...value, created]);
      announce(`Added ${trimmed}`);
      setInputValue('');
      setOpen(false);
      return;
    }
    if (selectedIds.has(String(opt.id))) return; // safety
    onChange([...value, opt]);
    announce(`Added ${getOptionLabel(opt)}`);
    setInputValue('');
    setOpen(false);
  };

  const removeAt = (i: number) => {
    const removed = value[i];
    const next = value.slice(0, i).concat(value.slice(i + 1));
    onChange(next);
    announce(`Removed ${getOptionLabel(removed)}`);
    // keep focus in input
    inputRef.current?.focus();
  };

  const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = (e) => {
    if (disabled) return;
    const hasQuery = inputValue.length > 0;
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!open && filtered.length) setOpen(true);
        setActiveIndex((i) => {
          if (filtered.length === 0) return null;
          return i == null ? 0 : Math.min(i + 1, filtered.length - 1);
        });
        break;
      case 'ArrowUp':
        e.preventDefault();
        setActiveIndex((i) => {
          if (filtered.length === 0) return null;
          return i == null ? filtered.length - 1 : Math.max(i - 1, 0);
        });
        break;
      case 'Enter':
        if (open && activeIndex != null && filtered[activeIndex]) {
          e.preventDefault();
          commit(filtered[activeIndex]);
        } else if (!isComposing && hasQuery) {
          e.preventDefault();
          commit(inputValue);
        }
        break;
      case 'Tab':
        if (open && activeIndex != null && filtered[activeIndex]) {
          commit(filtered[activeIndex]);
          e.preventDefault();
        }
        break;
      case ',':
        if (!isComposing && hasQuery) {
          e.preventDefault();
          commit(inputValue);
        }
        break;
      case 'Backspace':
        if (!hasQuery && value.length) {
          e.preventDefault();
          removeAt(value.length - 1);
        }
        break;
      case 'Escape':
        setOpen(false);
        setActiveIndex(null);
        break;
    }
  };

  const onPaste: React.ClipboardEventHandler<HTMLInputElement> = (e) => {
    const text = e.clipboardData.getData('text');
    if (!text) return;
    const parts = text
      .split(/[\n,;]+/)
      .map(s => s.trim())
      .filter(Boolean);
    if (parts.length <= 1) return; // let browser handle normal paste
    e.preventDefault();
    const toAdd: Option[] = [];
    for (const p of parts) {
      if (!allowCustom) {
        const found = options.find(o => getOptionLabel(o).toLowerCase() === p.toLowerCase());
        if (found && !selectedIds.has(String(found.id)) && !isOptionDisabled(found)) toAdd.push(found);
      } else {
        if (!value.some(v => getOptionLabel(v).toLowerCase() === p.toLowerCase())) {
          toAdd.push({ id: p, label: p });
        }
      }
      if (maxChips != null && value.length + toAdd.length >= maxChips) break;
    }
    if (toAdd.length) {
      onChange([...value, ...toAdd]);
      announce(`Added ${toAdd.length} item${toAdd.length > 1 ? 's' : ''} from paste`);
      setInputValue('');
      setOpen(false);
    }
  };

  return (
    <div className="chip-input" aria-disabled={disabled}>
      {/* live region for announcements */}
      <div className="sr-only" aria-live="polite" ref={liveRef} />

      <div className="chip-field" onClick={() => inputRef.current?.focus()}>
        <ul role="list" className="chip-list">
          {value.map((opt, i) => (
            <li key={opt.id} className="chip">
              <span className="chip-label">{getOptionLabel(opt)}</span>
              <button
                type="button"
                className="chip-remove"
                aria-label={`Remove ${getOptionLabel(opt)}`}
                onClick={(e) => { e.stopPropagation(); removeAt(i); }}
              >×</button>
            </li>
          ))}
          <li className="chip-input-wrapper">
            <input
              id={inputId}
              ref={inputRef}
              role="combobox"
              aria-autocomplete="list"
              aria-expanded={open}
              aria-controls={listboxId}
              aria-activedescendant={open && activeIndex != null && filtered[activeIndex] ? `${listboxId}-opt-${filtered[activeIndex].id}` : undefined}
              aria-label={ariaLabel}
              placeholder={placeholder}
              value={inputValue}
              onChange={(e) => { setInputValue(e.target.value); setOpen(true); }}
              onKeyDown={onKeyDown}
              onCompositionStart={() => setIsComposing(true)}
              onCompositionEnd={() => setIsComposing(false)}
              onPaste={onPaste}
              disabled={disabled || !canAddMore}
              autoComplete="off"
            />
            {name && (
              <input type="hidden" name={name} value={JSON.stringify(value.map(v => v.id))} />
            )}
          </li>
        </ul>
      </div>

      {open && (filtered.length > 0 || inputValue.trim()) && (
        <ul id={listboxId} role="listbox" className="chip-listbox">
          {filtered.length === 0 ? (
            <li className="option disabled" aria-disabled>{noOptionsText}</li>
          ) : (
            filtered.map((opt, i) => (
              <li
                id={`${listboxId}-opt-${opt.id}`}
                key={opt.id}
                role="option"
                aria-selected={activeIndex === i}
                className={`option ${activeIndex === i ? 'active' : ''}`}
                onMouseEnter={() => setActiveIndex(i)}
                onMouseDown={(e) => { e.preventDefault(); commit(opt); }}
              >
                {getOptionLabel(opt)}
              </li>
            ))
          )}
        </ul>
      )}
    </div>
  );
}

Minimal styles (optional)

.chip-field { display: flex; flex-wrap: wrap; border: 1px solid #d0d7de; padding: 6px; border-radius: 8px; cursor: text; }
.chip-list { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 0; padding: 0; list-style: none; }
.chip { display: inline-flex; align-items: center; gap: 6px; background: #eef2ff; color: #1e2a78; border-radius: 999px; padding: 4px 8px; }
.chip-remove { appearance: none; border: 0; background: transparent; cursor: pointer; font-size: 14px; line-height: 1; }
.chip-input-wrapper input { border: none; outline: none; min-width: 120px; padding: 4px; }
.chip-listbox { margin-top: 6px; border: 1px solid #d0d7de; border-radius: 8px; max-height: 240px; overflow: auto; background: white; box-shadow: 0 8px 24px rgba(0,0,0,.08); }
.option { padding: 8px 12px; cursor: pointer; }
.option.active { background: #e6f0ff; }
.option.disabled { color: #666; cursor: default; }
.sr-only { position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden; }

Async data and debouncing

Fetching suggestions from an API? Debounce the query and cancel in‑flight requests to avoid race conditions.

import { useEffect, useMemo, useRef, useState } from 'react';

export function useDebouncedOptions<T>(
  query: string,
  fetcher: (q: string, signal: AbortSignal) => Promise<T[]>,
  delay = 200
) {
  const [items, setItems] = useState<T[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const timer = useRef<number | null>(null);
  const ctrl = useRef<AbortController | null>(null);

  useEffect(() => {
    if (timer.current) window.clearTimeout(timer.current);
    if (ctrl.current) ctrl.current.abort();
    if (!query.trim()) { setItems([]); return; }
    setLoading(true);
    setError(null);
    const controller = new AbortController();
    ctrl.current = controller;
    timer.current = window.setTimeout(() => {
      fetcher(query, controller.signal)
        .then(setItems)
        .catch((e) => { if (e.name !== 'AbortError') setError(e); })
        .finally(() => setLoading(false));
    }, delay);
    return () => {
      if (timer.current) window.clearTimeout(timer.current);
      controller.abort();
    };
  }, [query, fetcher, delay]);

  return { items, loading, error };
}

Integrate by wiring items into the component’s options and showing a loading row in the listbox.

Performance tips

  • Virtualize the list: For thousands of options, render only visible rows using react-window/react-virtual.
  • Memoize heavy operations: useMemo for filtering and useCallback for handlers passed to children.
  • Key stability: use stable option ids, not array indexes.
  • Avoid re-renders: Keep parent state minimal; lift only value/onChange.

Example virtualization snippet:

import { FixedSizeList as List } from 'react-window';

function VirtualizedListbox({ items, activeIndex, onSelect }: {
  items: Option[]; activeIndex: number | null; onSelect: (o: Option) => void;
}) {
  return (
    <List height={240} itemCount={items.length} itemSize={36} width={'100%'}>
      {({ index, style }) => (
        <div style={style} role="option" aria-selected={activeIndex===index}
             className={`option ${activeIndex===index ? 'active': ''}`}
             onMouseDown={(e) => { e.preventDefault(); onSelect(items[index]); }}>
          {items[index].label}
        </div>
      )}
    </List>
  );
}

IME, mobile, and RTL considerations

  • Composition events: Never commit on Enter/Comma while isComposing is true.
  • Touch: Ensure at least 40–44 px hit targets for chips and options.
  • Scroll into view: When navigating with keys, ensure the active option is visible.
  • RTL: Respect direction by inheriting dir from the page; avoid hard‑coded margin-left; use logical CSS (margin-inline-start).

Validation rules to consider

  • Maximum chips: Prevent commit and show a hint when limit is reached.
  • Duplicate policy: Block duplicates by id or label; optionally allow near‑duplicates with normalization.
  • Allowed values only: If allowCustom is false, commit only items from options.
  • Trimming and normalization: Normalize whitespace and case for comparisons.

Integrating with UI libraries (quick paths)

If you prefer to avoid building from scratch, mature libraries already cover most edge cases:

  • MUI (Material UI) Autocomplete
import Autocomplete from '@mui/material/Autocomplete';
import TextField from '@mui/material/TextField';

<Autocomplete
  multiple
  freeSolo // allow custom chips
  options={options}
  getOptionLabel={(o) => typeof o === 'string' ? o : o.label}
  onChange={(_, newValue) => setValue(newValue as any)}
  renderInput={(params) => <TextField {...params} label="Labels" placeholder="Add…" />}
/>
  • Downshift (headless)
import { useCombobox } from 'downshift';
// Use useCombobox for the listbox and manage chips yourself for full control.
  • React Select (Creatable)
import CreatableSelect from 'react-select/creatable';
<CreatableSelect isMulti onChange={(v) => setValue(v as any)} options={options} />

These options handle ARIA, keyboarding, and virtualization hooks out of the box, and are themeable.

Testing checklist

  • Keyboard: ArrowUp/Down, Enter, Tab, Backspace on empty input, Escape.
  • Screen readers: Verify roles/labels in NVDA, JAWS, VoiceOver; ensure announcements on add/remove.
  • Mouse: Hover/Click selection, click outside to close, chip remove button.
  • Mobile: Touch targets, virtual keyboard doesn’t obstruct the list, scroll behavior.
  • IME: Compose characters (e.g., Japanese), ensure Enter does not prematurely commit.
  • Pasting multiple values with commas/newlines.
  • Performance: 10k options with virtualization.

Common pitfalls

  • Treating chips as focusable without a clear focus order—keep focus in the input and expose remove buttons for each chip.
  • Forgetting aria-activedescendant—screen readers won’t announce the highlighted option.
  • Committing on every delimiter even during IME composition—blocks CJK input.
  • Not clearing announcements—subsequent identical messages may not be read.
  • Parsing paste without trimming—creates empty/duplicate chips.

Wrapping up

A great chip input autocomplete balances ergonomics, accessibility, and performance. Start with a clean state model, follow the combobox + listbox pattern, handle IME and paste, and add virtualization as needed. Whether you implement it yourself for full control or leverage a UI library, the patterns above will help you ship a robust, inclusive component with confidence.

Related Posts