A generic, accessible, keyboard-navigable multi-select autocomplete component built from scratch with React and TypeScript.
- Fully generic — works with
string, objects, or any custom type viagetLabel/getKeyprops - Dynamic item creation — "Create X" option appears when input doesn't match any suggestion
- Keyboard navigation —
↑↓to move,Enterto select,Backspaceto remove last tag,Escapeto close - Controlled component — state lives in the parent, component only reports changes via
onChange - WAI-ARIA compliant —
role="combobox",aria-expanded,aria-activedescendant,role="listbox",role="option" - Click outside to close — via
mousedownlistener ondocument - No external autocomplete libraries used
npm install
npm run dev<Autocomplete<T>
options={T[]} // full list of available options
tags={T[]} // currently selected items (controlled)
onChange={(selected: T[]) => void} // called on every change
getLabel={(item: T) => string} // how to display an item
getKey={(item: T) => string} // unique identifier for an item
placeholder?: string
allowCreate?: boolean // show "Create X" when no match found
onCreate?: (inputValue: string) => T // how to construct a new T from typed text
/>| Prop | Type | Required | Description |
|---|---|---|---|
options |
T[] |
✅ | List of available options |
tags |
T[] |
✅ | Currently selected items |
onChange |
(selected: T[]) => void |
✅ | Fired on every selection or removal |
getLabel |
(item: T) => string |
✅ | Extracts display text from an item |
getKey |
(item: T) => string |
✅ | Extracts unique key from an item |
placeholder |
string |
— | Input placeholder text |
allowCreate |
boolean |
— | Enables creating items not in the list |
onCreate |
(val: string) => T |
— | Builds a new T from typed text |
const LANGUAGES = ["English", "French", "German", "Polish"];
const [langs, setLangs] = useState<string[]>([]);
<Autocomplete<string>
options={LANGUAGES}
tags={langs}
onChange={setLangs}
getLabel={s => s}
getKey={s => s}
allowCreate
onCreate={val => val}
placeholder="Search languages..."
/>interface Address {
id: string;
streetName: string;
city: string;
}
const ADDRESSES: Address[] = [
{ id: "1", streetName: "Wawel 5", city: "Krakow" },
{ id: "2", streetName: "Unter den Linden 1", city: "Berlin" },
{ id: "3", streetName: "Piazza del Colosseo", city: "Rome" },
];
const [addresses, setAddresses] = useState<Address[]>([]);
<Autocomplete<Address>
options={ADDRESSES}
tags={addresses}
onChange={setAddresses}
getLabel={a => `${a.streetName}, ${a.city}`}
getKey={a => a.id}
placeholder="Search addresses..."
/>The component is identical in both cases — only getLabel and getKey change.
src/
Autocomplete.tsx — generic component + AutocompleteOptions interface
Tag.tsx — selected item chip with remove button
Option.tsx — single dropdown row
InputArea.tsx — controlled input with full ARIA attributes
App.tsx — demo: Languages (strings) + Addresses (objects)
App.css — all styles
getLabel / getKey as functions, not strings
A labelKey: "name" prop only works for flat objects with known fields. Functions handle any shape of data — nested properties, computed values like `${streetName}, ${city}`, formatted dates, anything.
onMouseDown instead of onClick on options
onClick fires after onBlur. When clicking an option, the input loses focus first — closing the dropdown before the click registers. onMouseDown fires before onBlur, so the selection always goes through.
Options never stored in state
Removing selected items from an options state array makes them unrecoverable if a tag is removed. Instead, options is a static prop and already-selected items are excluded at render time using derived state.
Controlled component pattern
The component holds no knowledge of which items are selected — that state lives in the parent. This follows the same pattern as a standard <input value={x} onChange={...} /> and makes the component predictable and easy to integrate.
| Attribute | Element | Purpose |
|---|---|---|
role="combobox" |
<input> |
Identifies the widget type |
aria-expanded |
<input> |
Reflects dropdown open/closed state |
aria-haspopup="listbox" |
<input> |
Signals what the popup is |
aria-autocomplete="list" |
<input> |
Describes autocomplete behaviour |
aria-activedescendant |
<input> |
Points to the currently highlighted option |
role="listbox" |
<ul> |
Identifies the dropdown list |
role="option" |
<li> |
Identifies each item |
aria-selected |
<li> |
Reflects keyboard highlight state |
aria-label |
remove <button> |
e.g. "remove English" |
- React 18
- TypeScript
- Vite
- Zero external autocomplete libraries