Chip & Tag Input: Building Gmail-Style Tag Components in React
Build Gmail-style chip and tag input components in React with Tailwind v4. Keyboard nav, deletable tags, autocomplete, and accessible markup — no library needed.
Why Gmail's Tag Input Is So Hard to Copy
Honestly, the chip input is one of those UI patterns that looks simple until you actually try to build it. Gmail's To: field has been around for years, and developers still reach for bloated libraries just to get the basics working. That's a problem worth solving from scratch.
The core challenge isn't rendering the chips themselves — that's two divs and some padding. The hard part is the state machine underneath: when does a tag commit? On comma, on Enter, on blur? What happens when someone pastes 'react, vue, angular' as a single string? How do you handle backspace to delete the last chip without nuking text they're mid-typing?
This article walks through building a production-ready chip input component in React using Tailwind v4.0.2. No wrappers. No dependencies beyond React itself. We'll cover keyboard navigation, paste handling, duplicate prevention, and accessible markup that screen readers actually understand.
The Data Model: Tags Are Just an Array of Strings
Before touching JSX, get the state model right. A chip input has two pieces of state: the committed tags (an array) and the in-progress input value (a string). That's it. Don't reach for something fancier.
The tricky bit is deciding which events promote the input string into a tag. The most predictable approach: commit on Enter, commit on comma, and commit on blur if there's non-empty text. Backspace on an empty input removes the last tag. This matches what users expect from Gmail, Notion, and Linear.
One thing people miss early on — you need to call .trim() on the value before committing, and you need to guard against empty strings and duplicates. A Set check is the cleanest way: if (value.trim() && !tags.includes(value.trim())). Otherwise your users will end up with ghost chips from accidental double-enters.
Building the Core ChipInput Component
Here's the full component. It handles Enter, comma, backspace, blur, and paste in about 80 lines. We're using Tailwind v4.0.2 utility classes and keeping the focus ring accessible.
import { useState, useRef, KeyboardEvent, ClipboardEvent } from 'react';
interface ChipInputProps {
value: string[];
onChange: (tags: string[]) => void;
placeholder?: string;
maxTags?: number;
}
export function ChipInput({
value: tags,
onChange,
placeholder = 'Add tag…',
maxTags = 20,
}: ChipInputProps) {
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const addTag = (raw: string) => {
const tag = raw.trim();
if (!tag || tags.includes(tag) || tags.length >= maxTags) return;
onChange([...tags, tag]);
};
const removeTag = (index: number) => {
onChange(tags.filter((_, i) => i !== index));
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(inputValue);
setInputValue('');
} else if (e.key === 'Backspace' && inputValue === '') {
removeTag(tags.length - 1);
}
};
const handlePaste = (e: ClipboardEvent<HTMLInputElement>) => {
e.preventDefault();
const pasted = e.clipboardData.getData('text');
const parts = pasted.split(/[,\n]+/);
const next = [...tags];
for (const part of parts) {
const tag = part.trim();
if (tag && !next.includes(tag) && next.length < maxTags) {
next.push(tag);
}
}
onChange(next);
};
return (
<div
className="flex flex-wrap gap-2 rounded-lg border border-white/20 bg-white/5 p-2 focus-within:ring-2 focus-within:ring-violet-500"
onClick={() => inputRef.current?.focus()}
role="group"
aria-label="Tag input"
>
{tags.map((tag, i) => (
<span
key={tag}
className="flex items-center gap-1 rounded-full bg-violet-600/20 px-3 py-1 text-sm text-violet-300"
>
{tag}
<button
type="button"
onClick={() => removeTag(i)}
aria-label={`Remove ${tag}`}
className="ml-1 rounded-full p-0.5 hover:bg-violet-500/40"
>
×
</button>
</span>
))}
<input
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => { addTag(inputValue); setInputValue(''); }}
onPaste={handlePaste}
placeholder={tags.length === 0 ? placeholder : ''}
className="min-w-[120px] flex-1 bg-transparent text-sm text-white outline-none placeholder:text-white/30"
aria-label="Add a tag"
/>
</div>
);
}A few things worth calling out in this code. The container div has focus-within:ring-2 focus-within:ring-violet-500 — that gives you the Gmail-style effect where the whole field lights up when focused, not just the raw input. The 8px gap between chips comes from gap-2 in Tailwind v4. And each remove button has an explicit aria-label so screen reader users hear 'Remove react' instead of just 'button'.
Styling Chips: Color Variants and Dark Mode
Default chips look fine in a monochrome purple. But real apps need color-coded tags — think priority labels in Linear or Gmail's colored category chips. The cleanest approach is a variant prop that maps to a Tailwind class tuple.
For dark mode support, lean on rgba(255,255,255,0.15) as a background rather than hard-coded color values. That single token works on dark backgrounds without needing a separate dark: variant. You can still accent with colored text. Check out how Empire UI handles theme switching in the theme toggle component if you want the full dark/light toggle pattern.
One thing that trips people up: chip border-radius. A pill shape (rounded-full) reads as a tag. A rounded-md chip reads more like a badge. Gmail uses the pill. Notion uses a squircle-adjacent rounded rect. Pick one and be consistent — mixing them in the same input looks wrong even if users can't articulate why. If you're building design system components more broadly, the animated button component covers the same radius decision for interactive elements.
Autocomplete Dropdown Integration
A tag input without suggestions is fine for free-form data. But if you're letting users select from a known set — like GitHub topics, or product categories — you need a dropdown. The pattern is a suggestions prop filtered against the current input value, rendered as an absolutely-positioned list below the container.
Keep the suggestion list in a <ul role="listbox"> with each item as <li role="option" aria-selected={...}>. That's the correct ARIA pattern. Keyboard navigation through suggestions needs its own state: a focusedIndex integer that responds to ArrowUp/ArrowDown. Pressing Enter selects the focused suggestion and commits it as a tag. Pressing Escape closes the dropdown without committing.
Don't forget to filter out already-selected tags from the suggestion list. It's annoying to see 'react' in suggestions when 'react' is already in your tag list. One line: suggestions.filter(s => !tags.includes(s) && s.toLowerCase().startsWith(inputValue.toLowerCase())). The startsWith is more predictable than fuzzy matching for short tag strings.
Accessibility and Keyboard Navigation
Can someone actually use this component with a keyboard alone? That's the real question. Most chip input implementations fail this test. They're clickable, but once you've added chips, you can't navigate to them with Tab and delete them without a mouse.
The proper pattern: Tab moves focus into the input as normal. When the input is empty, a left-arrow keypress should shift focus to the last chip. From there, left/right arrows navigate between chips, Delete or Backspace removes the focused chip, and Escape or right-arrow from the last chip returns focus to the input. This mirrors how accessible tag inputs work in mature design systems.
To implement chip focus, give each chip a tabIndex={-1} and a ref array, then manage focus imperatively: chipRefs.current[i]?.focus(). It's a bit of DOM manipulation but it's the right tool. The alternative — making chips tabIndex={0} — pollutes the tab order for keyboard users who aren't trying to manage tags. Worth reading through the WCAG 2.1 SC 2.1.1 guidance if you're building this for a product with compliance requirements.
Using ChipInput with React Hook Form
Most tag inputs eventually need to live inside a form. React Hook Form is the practical choice in 2026 — it's performant, and its Controller component handles custom inputs cleanly.
import { useForm, Controller } from 'react-hook-form';
import { ChipInput } from './ChipInput';
type FormValues = { tags: string[] };
export function TagForm() {
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: { tags: [] },
});
const onSubmit = (data: FormValues) => {
console.log('Submitted tags:', data.tags);
};
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<Controller
name="tags"
control={control}
rules={{ validate: v => v.length > 0 || 'Add at least one tag' }}
render={({ field, fieldState }) => (
<div>
<ChipInput
value={field.value}
onChange={field.onChange}
placeholder="Add tags…"
maxTags={10}
/>
{fieldState.error && (
<p className="mt-1 text-xs text-red-400">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
<button type="submit" className="rounded-lg bg-violet-600 px-4 py-2 text-sm text-white">
Submit
</button>
</form>
);
}The Controller wrapper handles the bridge between RHF's internal state and the chip input's value/onChange props. Validation runs on submit. If you need per-tag validation (e.g., tags must be valid slugs), pass a validate function that checks the array contents. This same pattern works for other complex inputs — if you're building multi-select controls, the animated tabs component shows a similar controlled/uncontrolled pattern you can adapt.
Performance and Edge Cases to Watch
For small tag lists — under 50 items — performance is a non-issue. But if you're building something like a filter UI where users can add hundreds of tags, or you're rendering many ChipInput instances on a page simultaneously, a few things matter.
First, memoize the addTag and removeTag callbacks with useCallback. Without this, every keystroke in the text input re-creates these functions, which causes chip children to re-render even when they haven't changed. Add React.memo to the individual Chip component as a second layer. These aren't premature optimizations — they're correct defaults for a component you'll reuse across a codebase.
Edge cases that bite in production: pasting from Excel (cells are tab-separated, not comma-separated — handle \t in your split regex), pasting with trailing commas ('react, vue, ' should not create an empty tag), and mobile keyboards that don't fire a keydown for Enter on some Android browsers. For mobile, listen to the onChange event and check if the last character is a comma as a fallback commit trigger. It's ugly but it works.
FAQ
Before pushing a new tag into the array, check with tags.includes(tag.trim()). If it's already present, return early without updating state. You can also show a brief shake animation on the existing chip to give visual feedback that the duplicate was detected.
Many Android soft keyboards don't fire a proper keydown event for Enter. The fix is to also listen to onChange and check if the input value ends with a comma: if (e.target.value.endsWith(',')) { addTag(e.target.value.slice(0, -1)); setInputValue(''); }. This catches the commit event on mobile even when keydown is unreliable.
Pass a maxTags prop and check tags.length >= maxTags in your addTag function before pushing. You should also visually disable or hide the input when the limit is reached so users understand why typing isn't working. Add aria-disabled="true" and placeholder="Max tags reached" on the input at that point.
Yes — wrap it in RHF's Controller component. The render prop gives you field.value and field.onChange which map directly to the chip input's value and onChange props. For validation, use the rules.validate option to check the array length or contents.
Assign tabIndex={-1} to each chip and store refs in an array. When the user presses ArrowLeft on an empty input, call chipRefs.current[tags.length - 1]?.focus() to move focus to the last chip. Then on ArrowLeft/ArrowRight within chips, navigate the ref array. Delete or Backspace on a focused chip removes it and moves focus to the previous chip or back to the input.
The container should be role='group'. The input itself should have aria-label='Add a tag' and aria-autocomplete='list' when suggestions are shown. The suggestion dropdown should be role='listbox' with each item as role='option' and aria-selected set to true/false. Use aria-activedescendant on the input to point to the currently highlighted suggestion's id.