Rxova
Skip to content

Tags input — About

The decisions behind the component, and the ones you should disagree with deliberately rather than by accident.

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:

  1. the next tag, if there is one,
  2. otherwise the previous tag,
  3. 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.

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.

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.

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.

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.

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 transform behaves as if it were absent
  • a throwing validate refuses 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.

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 value is 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-max entries 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.

onWarn receives { code, prop, received, message }.

CodeMeaning
value-not-arrayThe prop is not an array at all; an empty list renders
value-had-non-stringsEntries dropped rather than stringified
value-had-duplicatesRepeats dropped; pass allowDuplicates if they are meaningful
value-over-maxMore tags than max; the extras were dropped
max-invalidmax below 1 — a field that can hold no tags is not a field
length-range-invalidminLength above maxLength; both are ignored
no-delimitersNothing 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.

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 clipboardData passed to the ClipboardEvent constructor, 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.

There is no stylesheet to import.

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.

These are public API, covered by semver.

AttributeOnMeaning
data-rx-tags-rootwrapperAlways present
data-countwrapperNumber of tags
data-fullwrappermax reached
data-invalid / data-disabled / data-readonlywrapperMirrors 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-focusedremove buttonThis tag holds the roving tab stop
data-rx-tags-input<input>The entry box
data-rx-tags-valuehidden inputOne per tag, for form submission
data-rx-tags-announcementlive regionOff-screen, aria-live="polite"

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.

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.

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}
/>
)}
/>
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.

<Field name="skills">
{({ input }) => (
<TagsInput
label="Tags"
name={input.name}
value={Array.isArray(input.value) ? input.value : []}
onChange={input.onChange}
onBlur={input.onBlur}
/>
)}
</Field>
<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.

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>
  )
}