EmpireUI
Get Pro
← Blog8 min read#multi-select#react#tags

Multi-Select in React: Tags Input, Checkboxes and Combobox Patterns

Build multi-select UIs in React — tags input, checkbox lists, and accessible combobox patterns. Real code, keyboard nav, and no hand-waving about "best practices".

Code editor showing React component with multi-select dropdown interface

Three Patterns, One Problem

Multi-select is one of those UI problems that looks simple until you're three hours deep in focus-trap bugs and wondering why your keyboard users can't exit the dropdown. Every app eventually needs it — filter sidebars, tag editors, permission pickers, recipient fields. The question isn't whether you need it, it's which pattern actually fits.

There are three main approaches: a checkbox list (visible, no hidden state), a tags input (type-to-add, remove with backspace), and a combobox/dropdown hybrid that shows a searchable list and renders selected items as chips. Each has different accessibility requirements, different state shapes, and — honestly — different failure modes you'll hit in production.

This article covers all three with working code. We'll start simple and build up. No library required for the first two; the combobox section mentions when reaching for Radix or Headless UI is the right call versus rolling your own.

Quick aside: if you're working on a styled UI and want the visual side sorted before touching interaction logic, browse components — there's a filter system you can pull apart. But the patterns below work with any design system.

Checkbox List: The Underrated Starting Point

Half the time a checkbox list is the right answer and designers over-engineer it into a dropdown. If you have fewer than ~8 options and they're all visible at once, a plain list of checkboxes is faster to implement, better for screen readers out of the box, and requires zero keyboard management on your end. The browser handles it.

The state is simple: an array of selected values. Here's the pattern you'll use everywhere: ``tsx import { useState } from 'react'; const OPTIONS = [ { value: 'react', label: 'React' }, { value: 'vue', label: 'Vue' }, { value: 'svelte', label: 'Svelte' }, { value: 'angular', label: 'Angular' }, ]; export function CheckboxMultiSelect({ value, onChange, }: { value: string[]; onChange: (v: string[]) => void; }) { const toggle = (val: string) => value.includes(val) ? onChange(value.filter((v) => v !== val)) : onChange([...value, val]); return ( <fieldset> <legend className="text-sm font-medium">Frameworks</legend> {OPTIONS.map((opt) => ( <label key={opt.value} className="flex items-center gap-2 py-1"> <input type="checkbox" value={opt.value} checked={value.includes(opt.value)} onChange={() => toggle(opt.value)} /> {opt.label} </label> ))} </fieldset> ); } ``

Note the fieldset + legend — that's not optional decoration. Screen readers use the legend to announce what group of checkboxes you're in. Drop it and NVDA users get "React checkbox" with zero context. Add it and they get "Frameworks group, React checkbox". Costs you nothing.

In practice, this pattern handles 80% of filter UIs in admin dashboards. Where it falls apart: more than ~12 options (scrolling a checkbox list feels terrible), dynamic options loaded from an API, or a design that calls for inline tag chips rather than a stacked list.

Worth noting: React 19's use hook doesn't change anything here. This is controlled state, no suspense involved.

Tags Input: Type-to-Add with Backspace-to-Remove

A tags input is the pattern for open-ended entry — email recipients, skill tags, keyword fields. The user types something, hits Enter (or comma), and it becomes a chip. Backspace on an empty input removes the last chip. It feels natural, but getting the keyboard behavior right is where most implementations stumble.

Here's a minimal implementation that actually handles the edge cases: ``tsx import { useState, useRef, KeyboardEvent } from 'react'; export function TagsInput({ value, onChange, placeholder = 'Add a tag…', }: { value: string[]; onChange: (tags: string[]) => void; placeholder?: string; }) { const [input, setInput] = useState(''); const inputRef = useRef<HTMLInputElement>(null); const addTag = (tag: string) => { const trimmed = tag.trim(); if (trimmed && !value.includes(trimmed)) { onChange([...value, trimmed]); } setInput(''); }; const removeTag = (tag: string) => onChange(value.filter((t) => t !== tag)); const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { if ((e.key === 'Enter' || e.key === ',') && input) { e.preventDefault(); addTag(input); } if (e.key === 'Backspace' && !input && value.length) { removeTag(value[value.length - 1]); } }; return ( <div className="flex flex-wrap gap-1.5 rounded-md border px-3 py-2" onClick={() => inputRef.current?.focus()} role="group" aria-label="Selected tags" > {value.map((tag) => ( <span key={tag} className="flex items-center gap-1 rounded bg-blue-100 px-2 py-0.5 text-sm text-blue-800" > {tag} <button type="button" aria-label={Remove ${tag}} onClick={() => removeTag(tag)} className="text-blue-500 hover:text-blue-900" > × </button> </span> ))} <input ref={inputRef} value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => input && addTag(input)} placeholder={value.length === 0 ? placeholder : ''} className="min-w-[120px] flex-1 outline-none text-sm" aria-label="Add tag" /> </div> ); } ``

The onBlur handler that calls addTag is a UX nicety — if the user clicks away mid-type, the tag gets committed rather than lost. Some people hate this behavior; make it a prop if you're building for others.

Accessibility here is trickier than it looks. Each remove button needs a unique aria-label ("Remove react" not just "×"). The container div with role="group" helps screen readers group the chips. You won't get a perfect ARIA listbox experience from this pattern — for that you need the combobox approach below — but for simple tagging UIs, this is good enough and much simpler.

One more thing — the min-w-[120px] on the input prevents the cursor from collapsing to 0px when there are many tags. Tailwind 3.x handles this fine; if you're on plain CSS, min-width: 120px on the input does the same job.

Combobox with Search: The Full Pattern

When you have 20+ options, async search, or need to meet WCAG 2.1 AA (which you probably do if this is a product used at work), you want a proper combobox. This is the pattern Gmail uses for the To: field, GitHub uses for label selectors, and Linear uses everywhere. It's also the one with the most accessibility surface area.

Honestly, this is the one case where I'd seriously consider Radix UI's Combobox (or Headless UI's equivalent) rather than rolling from scratch. The ARIA pattern for a multi-select combobox — role="combobox", aria-expanded, aria-controls, aria-activedescendant, managing focus between input and listbox — is about 200 lines of DOM wiring that Radix has already debugged across 50 browser/screen-reader combinations. Your time is better spent on the actual UX logic.

That said, here's the state shape you're working with, which is the same whether you use a library or not: ``tsx type Option = { value: string; label: string }; type ComboboxState = { selected: Option[]; // committed selections query: string; // current search input open: boolean; // listbox visibility highlighted: string | null; // aria-activedescendant target }; ``

The interactions that need to work: Arrow Down/Up to navigate the list, Enter to select/deselect the highlighted item, Escape to close the dropdown and return focus to the input, Backspace on empty input to remove the last selected item (same as tags input), Tab to exit the widget entirely. Miss any of these and you'll hear about it in your accessibility audit.

For the visual layer — chips inside the input, the dropdown list with checkmarks, the "3 selected" overflow label — Empire UI's component library has pre-built patterns you can adapt. If you're on a glassmorphism design system, those frosted-glass dropdowns pair well with combobox patterns. The interaction logic stays the same; you're just swapping the CSS.

Keyboard Navigation: The Part Most Devs Skip

Let's be direct: if your multi-select component only works with a mouse, it's broken. Not "could be improved" — broken. WCAG 2.1 Success Criterion 2.1.1 requires all functionality be keyboard operable. If your product is used in enterprise or government contexts (or just by anyone who prefers keyboard navigation), you'll get bug reports.

For the dropdown combobox, the keyboard contract is: `` ArrowDown → move highlight down (wrap at bottom) ArrowUp → move highlight up (wrap at top) Enter → toggle highlighted option Escape → close dropdown, focus input Tab → close dropdown, move focus out Backspace → (on empty input) remove last selected Home/End → jump to first/last option ``

The tricky one is aria-activedescendant. You don't move actual DOM focus into the listbox (that would yank the screen reader's cursor away from the input). Instead, the input stays focused and you set aria-activedescendant to the id of the highlighted li element. Screen readers then read that element's label automatically. You need unique IDs on every option: id={\option-${opt.value}\}.

Worth noting: macOS VoiceOver + Chrome has had bugs with aria-activedescendant since at least 2022. If you're testing and VoiceOver isn't announcing the highlighted item, you're not alone — it's a known browser bug, not your implementation. NVDA + Firefox and JAWS + Chrome are your primary targets for enterprise compliance.

If you want to see how accessible patterns translate into real component APIs, the react-accessibility-guide article covers focus management fundamentals that apply directly here.

State Management and Form Integration

Multi-select state in forms has one consistent footgun: forgetting that string[] and string are different types, and your form library treating them differently. React Hook Form, Zod, and most form libraries handle arrays fine — but you have to tell them explicitly.

With React Hook Form v7+: ``tsx import { useForm, Controller } from 'react-hook-form'; import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; const schema = z.object({ tags: z.array(z.string()).min(1, 'Select at least one tag'), }); function MyForm() { const { control, handleSubmit, formState: { errors } } = useForm({ resolver: zodResolver(schema), defaultValues: { tags: [] }, }); return ( <form onSubmit={handleSubmit((data) => console.log(data))}> <Controller name="tags" control={control} render={({ field }) => ( <TagsInput value={field.value} onChange={field.onChange} /> )} /> {errors.tags && <p className="text-red-500 text-sm">{errors.tags.message}</p>} <button type="submit">Submit</button> </form> ); } ``

The Controller wrapper is almost always the right choice for custom inputs like this versus trying to make them work with register. You get clean value/onChange props and don't fight with ref forwarding.

Look, server actions in Next.js 14+ add a wrinkle here: if you're submitting via a <form> action rather than event.preventDefault(), arrays don't serialize nicely in FormData. A multi-select field tags[]=react&tags[]=vue needs to be parsed with formData.getAll('tags') on the server, not formData.get('tags'). Easy to miss, annoying to debug.

For async option loading — fetching options from an API as the user types — you want debouncing. A 300ms debounce on the search query prevents hammering your endpoint on every keystroke. useMemo won't help here since it's not referential equality that triggers the fetch; you want useEffect with a setTimeout cleanup, or just reach for use-debounce from npm.

Performance and Virtual Lists

If your options list can hit 500+ items — country selectors, user pickers in large orgs, product catalogues — you'll want virtualization. Rendering 500 li elements is fine on a fast laptop; on a mid-range Android it'll drop frames on open.

TanStack Virtual (v3) is the current standard for this. The setup with a combobox is about 30 lines: measure the listbox container, pass getVirtualItems() to render only the visible slice, set translateY on each item using the virtual item's start offset. The filtered options array stays as-is; you just control which elements mount. ``tsx import { useVirtualizer } from '@tanstack/react-virtual'; // Inside your combobox component: const parentRef = useRef<HTMLUListElement>(null); const virtualizer = useVirtualizer({ count: filteredOptions.length, getScrollElement: () => parentRef.current, estimateSize: () => 36, // 36px per row overscan: 5, }); return ( <ul ref={parentRef} style={{ height: '240px', overflow: 'auto' }}> <li style={{ height: virtualizer.getTotalSize() }}> {virtualizer.getVirtualItems().map((vItem) => { const opt = filteredOptions[vItem.index]; return ( <li key={opt.value} id={option-${opt.value}} style={{ position: 'absolute', top: 0, transform: translateY(${vItem.start}px), height: '36px', }} > {opt.label} </li> ); })} </li> </ul> ); ``

The container height of 240px (6 items at 36px each) is a common pattern — enough to show context without overwhelming the page. Adjust based on your item height; just make sure estimateSize matches your actual rendered row height or scrolling will jump.

One final thing worth getting right: memoize your filtered options. If filtering is pure (no API call), useMemo on the query + options array prevents recomputing the filter on every render. It's not life-changing for 50 items, but for 1000+ with complex matching logic, you'll notice it.

FAQ

Should I use a library or build multi-select from scratch in React?

For a checkbox list or simple tags input, build it — it's 50 lines and you control the styling. For an accessible combobox with keyboard nav and ARIA, Radix UI or Headless UI will save you days of browser compatibility debugging.

How do I make multi-select work with React Hook Form?

Use the Controller component with name, control, and a render prop that passes field.value and field.onChange to your custom input. Define the field type as z.array(z.string()) in your Zod schema.

What's the correct ARIA pattern for an accessible multi-select combobox?

The input gets role="combobox", aria-expanded, aria-controls pointing to the listbox id, and aria-activedescendant pointing to the highlighted option's id. Focus stays on the input; you never move DOM focus into the list.

How do I handle 500+ options in a multi-select dropdown without killing performance?

Use TanStack Virtual v3 to virtualize the list — only visible rows mount in the DOM. Combine it with a debounced search query (300ms) so you're filtering a manageable subset before virtualizing.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Number Input in React: Stepper, Clamp, Keyboard and TouchDate Picker in React: react-day-picker v9 and Custom ApproachReact Accessibility (a11y): The 8 Patterns You Keep Getting WrongWCAG 2025 Accessibility Guide for React Developers