A chip input — the field behind every recipient box, tag picker and "add skills" form — looks like a bespoke widget. It isn't. It's one plain <input> sitting inside a bordered box, plus an array of strings that is the single source of truth. Everything else is a handful of event handlers that all funnel into the same commit function. Here's how I built it, vanilla, no framework, no dependency.
One input, one array
The control is a flex container that wraps: zero or more chips followed by a single text input that flexes to fill the last line. The input is the only real form field; the chips are just a rendering of the array, injected before it. Two CSS tricks sell the illusion of one field — cursor:text plus a mousedown handler focuses the input from anywhere in the padding, and :focus-within draws the ring on the container so tabbing to the inner input lights up the whole box. That array is what a form would post, and it drives everything you see.
let chips = []; // the single source of truth
Committing text into a chip
A keystroke turns the current text into a chip. The commit keys are Enter and comma (a comma must never end up inside a chip, so we preventDefault). One commit() function is the only funnel — typing, pasting and picking a suggestion all go through it, so the rules live in exactly one place and can't drift apart.
input.addEventListener('keydown', e => {
if (e.key === 'Enter' || e.key === ','){
e.preventDefault(); // comma must not land in the text
commit(input.value);
}
});
Two ways to remove
Every chip carries a real <button> with a × and an aria-label="Remove {value}"; click it and that index is spliced out. And the classic: pressing Backspace while the input is empty eats the last chip, so a fast typist never has to reach for the mouse. Both paths call the same removeChip(i) — one array operation, then re-render.
Paste splits on commas
People paste alice, bob, carol or a whole column of newline-separated values. I intercept the paste event, and if the text contains a comma or newline I split it and feed every piece through commit(). The same splitter is reused by the Enter/comma path, so typing and pasting behave identically — no special case, no divergence.
const splitParts = raw => raw.split(/[,\n]/); // comma OR newline
The gatekeepers: dedup, max, validation
Nothing becomes a chip until it passes the gates. Trim it and drop empties. Reject a duplicate (compare case-insensitively) unless duplicates are explicitly allowed. Never exceed the max. Optionally validate the shape — an email regex, say. Crucially, each rejection returns a reason, so the field can shake and speak "that's not a valid email" through a live region instead of silently swallowing input. A rejected value never touches the array; the pipeline is the same every time — split, trim, validate, dedup, cap, render.
Autocomplete and keyboard, done right
Suggestions are a real ARIA combobox: the input gets role="combobox" and aria-expanded, pointing at a role="listbox". The highlighted option is tracked with aria-activedescendant, not real focus — focus stays in the input so typing never breaks. Left-arrow from an empty input steps focus into the chips, where arrow keys roam via roving tabindex and Delete removes, so the keyboard alone can reach any chip. And chip labels are built with textContent, never innerHTML, so a pasted <script> is inert. Reach for a chip input only when values are short, many and equal — tags, recipients, labels. If there's only ever one value, use a plain input; if the set is fixed and small, checkboxes or a multiselect read clearer.
Announce every change
The last piece is the part sighted users never see. An aria-live="polite" status region speaks "Added 3 tags" or the rejection reason, so a screen-reader user is never left guessing why a keystroke did nothing. Paired with the combobox roles and the roving focus, that turns a pile of divs into a control a keyboard-only or non-sighted user can actually operate — the difference between a demo and a field you'd ship.
Type, comma, paste, arrow into the chips — the whole thing runs live here:
Top comments (1)
How many post , you do in a day