Tags input — About
The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.
Focus after a removal
Section titled “Focus after a removal”The failure this component exists to fix.
Delete a tag with the keyboard and the button you were on ceases to exist. Unless something takes
focus deliberately, the browser drops it to <body>: a sighted keyboard user loses their place on
the page, and a screen-reader user is silently teleported to the top of the document.
So focus goes to:
- the next tag, if there is one,
- otherwise the previous tag,
- otherwise the entry box.
An adversarial test sweeps every position in the list, because the failure only shows at the ends — removing the middle tag looks fine in a hand-written test and the last one does not.
One tab stop, not one per tag
Section titled “One tab stop, not one per tag”Each remove button is a real <button>, so by default each would be its own tab stop. Twenty tags
would cost a keyboard user twenty presses to get past the field.
Instead the list uses a roving tabindex: exactly one button has tabIndex={0} at a time and
the rest have -1. Arrow keys move within the list, Home jumps to the first tag, End and
arrowing right past the last tag land in the entry box.
Arrowing right does not wrap back to the first tag — that would make the list a loop a user cannot arrow out of.
Two-step Backspace
Section titled “Two-step Backspace”Almost every tag field deletes the previous tag on a single Backspace in an empty box. That destroys data the user cannot see they are about to lose, especially at typing speed.
Here the first press selects the last tag — focus moves onto it, so it is visibly the thing under the cursor — and the second removes it. This is the change most likely to surprise someone migrating, and it is deliberate.
Not a combobox
Section titled “Not a combobox”Several packages in this category set role="combobox", aria-expanded and aria-autocomplete on
the entry box. Those attributes promise assistive technology a popup listbox with managed
active-descendant focus.
There is no popup here, so claiming it would be a lie that makes the field worse for the users it
is meant to help. This component is a labelled text box beside a real <ul> — which also means a
screen reader announces “list, 3 items” before reading them.
If you build a suggestion list on top of the headless hook, you own the combobox wiring for it.
Announcements
Section titled “Announcements”A polite live region announces additions and removals:
Added react. 3 tags.
Three deliberate choices:
- Polite, never assertive. A tag field is not an emergency; assertive would interrupt whatever the user is already hearing.
- A batch paste is announced once — “Added 5 tags. 5 tags.” Reading each of forty pasted tags in turn is not information.
- Rejections are silent by default. The refused text stays visibly in the box, and announcing every duplicate keystroke would talk over the user as they type.
announce replaces all of it, and returning '' silences it.
Rules and containment
Section titled “Rules and containment”transform runs before every other check, so lowercasing there genuinely makes React a duplicate
of react. Then, in order: empty, max, minLength, maxLength, duplicate, and finally
validate.
The cheap checks run first specifically so consumer code never sees an empty entry.
Both consumer hooks are contained:
- a throwing
transformbehaves as if it were absent - a throwing
validaterefuses the tag
Neither takes the form down. They run on every entry, and a typo in someone’s predicate should degrade one widget rather than unmount a page.
import { attempt } from '@rxova/react-tags-input'
attempt(['react'], 'React') // { tag: 'React', accepted: false, reason: 'duplicate' }attempt([], ' ') // { tag: '', accepted: false, reason: 'empty' }These helpers are exported and pure, so the same rules can run on the server against whatever the field submitted — the client-side check stops a typo, it does not secure anything.
The value
Section titled “The value”A string[], sanitised rather than trusted:
- non-strings are dropped, never stringified.
String(undefined)is"undefined", and a tag reading “undefined” in someone’s UI is a worse failure than a missing one. The warning says exactly that. - a controlled
valueis sanitised on every render rather than copied into state. Copying would let the two drift, and the field would keep showing a tag the parent no longer believes in. - duplicates and over-
maxentries are dropped, each with its own warning code, because they are different mistakes with different fixes.
Deduplication uses toLocaleLowerCase rather than toLowerCase: in Turkish, I lowercases to a
dotless ı, and a tag list is exactly where someone would notice “İstanbul” and “istanbul” being
treated as different words.
Lengths count codepoints, so two emoji are two characters and a maxLength of 2 accepts them.
Diagnostics
Section titled “Diagnostics”onWarn receives { code, prop, received, message }.
| Code | Meaning |
|---|---|
value-not-array | The prop is not an array at all; an empty list renders |
value-had-non-strings | Entries dropped rather than stringified |
value-had-duplicates | Repeats dropped; pass allowDuplicates if they are meaningful |
value-over-max | More tags than max; the extras were dropped |
max-invalid | max below 1 — a field that can hold no tags is not a field |
length-range-invalid | minLength above maxLength; both are ignored |
no-delimiters | Nothing commits a tag; Enter is restored |
With no handler these go to console.warn. The entire path is stripped from production builds,
and the E2E suite asserts that against a real production bundle.
Testing notes
Section titled “Testing notes”Two places where the harness, not the component, sets the limit — both made visible rather than worked around silently:
- The paste E2E skips itself in Firefox. Firefox ignores
clipboardDatapassed to theClipboardEventconstructor, so a synthesised paste arrives empty. The test detects that and skips with a message; the path is still covered in Chromium, WebKit and the browser suite. - Emoji tags are filled, not typed. The CDP keyboard channel mangles astral characters into replacement characters.
Styling
Section titled “Styling”There is no stylesheet to import.
Custom properties
Section titled “Custom properties”Set them on [data-rx-tags-root] or any ancestor:
[data-rx-tags-root] { --rx-tags-gap: 0.25rem; --rx-tags-tag-gap: 0.25rem; --rx-tags-tag-padding: 0.125rem 0.375rem; --rx-tags-tag-radius: 0.25rem; --rx-tags-tag-background: rgba(0 0 0 / 0.08); --rx-tags-remove-size: 1.5rem;}Keep --rx-tags-remove-size at 1.5rem or above: at the default font size that is the 24×24 CSS
pixels WCAG 2.5.8 Target Size (Minimum) requires. The entry box keeps the browser’s own focus ring;
to ring the whole field instead, draw one from [data-rx-tags-root]:focus-within and suppress the
inner one — but only once the replacement is in place.
Stable data-* hooks
Section titled “Stable data-* hooks”These are public API, covered by semver.
| Attribute | On | Meaning |
|---|---|---|
data-rx-tags-root | wrapper | Always present |
data-count | wrapper | Number of tags |
data-full | wrapper | max reached |
data-invalid / data-disabled / data-readonly | wrapper | Mirrors the props |
data-rx-tags-list | <ul> | The tag list |
data-rx-tags-tag | <li> | The tag’s index |
data-rx-tags-label | <span> | The tag’s rendered contents |
data-rx-tags-remove | <button> | The remove button |
data-focused | remove button | This tag holds the roving tab stop |
data-rx-tags-input | <input> | The entry box |
data-rx-tags-value | hidden input | One per tag, for form submission |
data-rx-tags-announcement | live region | Off-screen, aria-live="polite" |
Form libraries
Section titled “Form libraries”Every recipe below is transcribed from src/__tests__/form.browser.test.tsx, so it is code that
runs on every commit. The value is a string[], and a native submit emits one hidden input per tag rather than a joined string — so nobody downstream has to guess which separator was used, or what happens to a tag containing it.
Native forms
Section titled “Native forms”With a name, the component emits one hidden input per tag:
<form action="/profile" method="post"> <TagsInput name="skills" label="Skills" defaultValue={['react', 'a11y']} /> <button type="submit">Save</button></form>formData.getAll('skills') is ['react', 'a11y']. required applies to the entry box only while
the list is empty.
React Hook Form
Section titled “React Hook Form”onChange emits the whole array, so field.onChange binds directly:
<Controller name="skills" control={control} render={({ field }) => ( <TagsInput label="Tags" value={field.value} onChange={field.onChange} onBlur={field.onBlur} name={field.name} /> )}/>Formik
Section titled “Formik”function Field() { const [field, , helpers] = useField<string[]>('skills') return ( <TagsInput label="Tags" name="skills" value={field.value} onChange={(tags) => helpers.setValue(tags)} onBlur={() => helpers.setTouched(true)} /> )}onBlur fires when focus leaves the whole field, not when it moves onto a tag’s remove button — so navigating what you have already entered does not fire a validation error.
React Final Form
Section titled “React Final Form”<Field name="skills"> {({ input }) => ( <TagsInput label="Tags" name={input.name} value={Array.isArray(input.value) ? input.value : []} onChange={input.onChange} onBlur={input.onBlur} /> )}</Field>TanStack Form
Section titled “TanStack Form”<form.Field name="skills"> {(field) => ( <TagsInput label="Tags" name="skills" value={field.state.value} onChange={(value) => field.handleChange(value)} onBlur={field.handleBlur} /> )}</form.Field>React Final Form starts a field as '', which is not an array. The guard above is the binding’s
job, not the component’s.
UI-library recipes
Section titled “UI-library recipes”The tag list, roving focus, entry box, and live region stay together. These recipes add design-system field chrome without claiming combobox semantics or replacing keyboard behavior.
shadcn/ui
'use client'
import { TagsField } from '@/components/rxova/tags-field'
export function ShadcnTags() {
return (
<TagsField
label="Tags"
description="Press Enter or comma to add a tag."
name="tags"
defaultValue={['react']}
/>
)
}
Radix Themes
'use client'
import { Box, Text } from '@radix-ui/themes'
import { TagsInput } from '@rxova/react-tags-input'
export function RadixTags() {
return (
<Box>
<Text as="div" size="2" weight="bold" mb="1">
Tags
</Text>
<TagsInput label="Tags" name="tags" defaultValue={['react']} />
<Text as="div" size="1" color="gray" mt="1">
Press Enter or comma to add a tag.
</Text>
</Box>
)
}
Material UI
'use client'
import FormControl from '@mui/material/FormControl'
import FormHelperText from '@mui/material/FormHelperText'
import FormLabel from '@mui/material/FormLabel'
import { TagsInput } from '@rxova/react-tags-input'
export function MuiTags() {
return (
<FormControl>
<FormLabel>Tags</FormLabel>
<TagsInput label="Tags" name="tags" defaultValue={['react']} />
<FormHelperText>Press Enter or comma to add a tag.</FormHelperText>
</FormControl>
)
}
Chakra UI
'use client'
import { Field } from '@chakra-ui/react'
import { TagsInput } from '@rxova/react-tags-input'
export function ChakraTags() {
return (
<Field.Root>
<Field.Label>Tags</Field.Label>
<TagsInput label="Tags" name="tags" defaultValue={['react']} />
<Field.HelperText>Press Enter or comma to add a tag.</Field.HelperText>
</Field.Root>
)
}
Mantine
'use client'
import { Input } from '@mantine/core'
import { TagsInput } from '@rxova/react-tags-input'
export function MantineTags() {
return (
<Input.Wrapper label="Tags" description="Press Enter or comma to add a tag.">
<TagsInput label="Tags" name="tags" defaultValue={['react']} />
</Input.Wrapper>
)
}
Ant Design
'use client'
import { Form } from 'antd'
import { TagsInput } from '@rxova/react-tags-input'
export function AntTags() {
return (
<Form.Item label="Tags" extra="Press Enter or comma to add a tag.">
<TagsInput label="Tags" name="tags" defaultValue={['react']} />
</Form.Item>
)
}