Rxova
Skip to content

Tags input — Usage

Add a few tags, then try the keyboard: Backspace twice from the empty box, arrow between tags, Tab straight past the whole list. (TagsInput and useState are already in scope.)

Editable — try changing it
function TryIt() {
  const [tags, setTags] = useState(['react', 'a11y'])
  return (
    <div style={{ display: 'grid', gap: '0.75rem' }}>
      <TagsInput label="Topics" value={tags} onChange={setTags} placeholder="Add a topic" />
      <code>{JSON.stringify(tags)}</code>
    </div>
  )
}
import { useState } from 'react'
import { TagsInput } from '@rxova/react-tags-input'
function Controlled() {
const [tags, setTags] = useState<string[]>([])
return <TagsInput label="Topics" value={tags} onChange={setTags} />
}
function Uncontrolled() {
return <TagsInput label="Topics" defaultValue={['react']} />
}

onAdd and onRemove carry the delta if you need it, and onReject reports refusals.

KeyWhereEffect
Enter, ,entry boxCommit the current text
Backspaceempty entry boxSelect the last tag (a second press removes it)
empty entry boxMove into the tag list
Backspace, Deletea tagRemove it
/ a tagMove between tags; right past the last lands in the entry box
Homea tagJump to the first tag
Enda tagJump to the entry box
any charactera tagMove to the entry box, keeping the keystroke
TabanywhereLeave the field — one stop for the list, one for the box
Editable — try changing it
function Constrained() {
  const [rejection, setRejection] = useState(null)
  return (
    <div style={{ display: 'grid', gap: '0.5rem' }}>
      <TagsInput
        label="At most three lowercase topics"
        max={3}
        minLength={2}
        maxLength={12}
        transform={(raw) => raw.toLowerCase()}
        validate={(tag) => (tag.startsWith('x') ? 'no x-words here' : true)}
        onReject={setRejection}
      />
      <small>
        {rejection ? `${rejection.reason}: ${rejection.message ?? rejection.tag}` : ' '}
      </small>
    </div>
  )
}

transform runs first, so lowercasing there makes React a duplicate of react. validate returns true, false, or a string explaining the refusal. Both are contained: a throwing transform behaves as if absent, a throwing validate refuses the tag.

Reasons: empty, duplicate, max-reached, too-short, too-long, invalid.

A refused entry stays in the box so the user can fix it rather than retyping from memory.

import { TagsInput } from '@rxova/react-tags-input'
function Spaced() {
return <TagsInput label="Topics" delimiters={[' ', 'Enter']} />
}

Tab is supported but opt-in, because it changes what Tab means in a form — and even then it never commits an empty box, which would trap focus.

Pasting splits on the delimiters and on newlines regardless, because a paste from a spreadsheet column arrives newline-separated whatever the field was configured for. A single-value paste is left to the browser. Set splitPaste={false} to turn it off.

A multi-value paste consumes only the range it would have replaced: text already in the box survives either side of the caret, and the caret stays where it was. Anything the user had half-typed is theirs to finish, not something the paste throws away.

Keys arriving while an input method editor is composing are left to the IME. With a Japanese, Chinese or Korean keyboard the Enter that confirms a candidate is an ordinary keydown, and taking it as a delimiter would commit half-composed text and swallow the keystroke.

import { TagsInput } from '@rxova/react-tags-input'
function Form() {
return (
<form action="/profile" method="post">
<TagsInput label="Skills" name="skills" defaultValue={['react', 'a11y']} />
<button type="submit">Save</button>
</form>
)
}

One hidden input per tag, so formData.getAll('skills') gives ['react', 'a11y'] — not a joined string somebody downstream has to guess how to split.

No stylesheet to import.

[data-rx-tags-root] {
--rx-tags-tag-background: #e8f0fe;
--rx-tags-tag-radius: 999px;
}
[data-rx-tags-remove][data-focused] {
outline: 2px solid Highlight;
}
[data-rx-tags-root][data-full] [data-rx-tags-input] {
opacity: 0.5;
}

Do not shrink --rx-tags-remove-size below 1.5rem: at the default font size that is the 24×24 CSS pixels WCAG 2.5.8 requires. The full list of properties and data-* hooks is in the package README.

import { TagsInput } from '@rxova/react-tags-input'
function Coloured() {
return (
<TagsInput
label="Topics"
defaultValue={['react', 'a11y']}
renderTag={({ tag, focused }) => <strong data-focused={focused}>#{tag}</strong>}
removeLabel={(tag) => `Remove the ${tag} topic`}
/>
)
}

renderTag changes what is painted; the remove button is still named after the real tag, not the rendering.

useTagsInput gives you the whole state machine with no markup — including the roving tab order and the focus bookkeeping after a removal.

import { useTagsInput } from '@rxova/react-tags-input'
function CustomTags() {
const field = useTagsInput({ max: 5 })
return (
<div onBlur={field.handleBlur}>
<ul>
{field.tags.map((tag, index) => (
<li key={tag}>
{tag}
<button
ref={(node) => {
field.tagRefs.current[index] = node
}}
type="button"
tabIndex={index === field.activeIndex ? 0 : -1}
aria-label={`Remove ${tag}`}
onClick={() => {
field.removeAt(index)
}}
onKeyDown={(event) => {
field.handleTagKeyDown(event, index)
}}
onFocus={() => {
field.setFocusedIndex(index)
}}
>
×
</button>
</li>
))}
</ul>
<input
ref={(node) => {
field.inputRef.current = node
}}
value={field.text}
onChange={(event) => {
field.setText(event.target.value)
}}
onKeyDown={field.handleInputKeyDown}
onPaste={field.handlePaste}
/>
<span aria-live="polite">{field.announcement}</span>
</div>
)
}

The rule helpers are exported too — attempt, attemptAll, sanitize, splitPasted, contains — all pure, so the same rules can run on the server against whatever the field submitted.