Skip to content

Early previewhamur is not on npm yet. The components are being built in the open, follow along on GitHub.

InputsReact

Tags Input

A text field whose value is a list of tags.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { TagsInput } from '@hamur/react/tags-input'

function XIcon() {
  return (
    <svg className="tags-input-icon" viewBox="0 0 16 16" aria-hidden="true">
      <path d="M4 4l8 8M12 4l-8 8" />
    </svg>
  )
}

export default function TagsInputExample() {
  const [topics, setTopics] = React.useState(['react', 'vue', 'svelte'])
  return (
    <Field.Root className="field">
      <Field.Label>Topics</Field.Label>
      <TagsInput.Root
        className="tags-input"
        value={topics}
        onValueChange={(next: string[]) => setTopics(next)}
      >
        <TagsInput.List className="tags-input-list">
          {topics.map((topic) => (
            <TagsInput.Tag key={topic} value={topic} className="tags-input-tag">
              <TagsInput.TagContent className="tags-input-chip">
                <TagsInput.TagText className="tags-input-text" />
                <TagsInput.TagRemove className="tags-input-remove">
                  <XIcon />
                </TagsInput.TagRemove>
              </TagsInput.TagContent>
            </TagsInput.Tag>
          ))}
        </TagsInput.List>
        <TagsInput.Input
          className="tags-input-input"
          placeholder="Add a topic"
        />
        <TagsInput.Clear
          className="tags-input-clear"
          aria-label="Remove all topics"
        >
          <XIcon />
        </TagsInput.Clear>
      </TagsInput.Root>
      <Field.Description className="muted">
        Press Enter or type a comma to add a topic.
      </Field.Description>
    </Field.Root>
  )
}
Not measured yetTests passing in React
Not measured yetSize
Not measured yetMemory
Not measured yetSpeed
[N] commits

Installation

npm i @hamur/react

Not on npm yet. The package goes out under the chosen name.

Anatomy

Import the component and assemble its parts:

<TagsInput.Root>
  <TagsInput.List>
    <TagsInput.Tag>
      <TagsInput.TagContent>
        <TagsInput.TagText />
        <TagsInput.TagRemove />
      </TagsInput.TagContent>
    </TagsInput.Tag>
  </TagsInput.List>
  <TagsInput.Input />
  <TagsInput.Clear />
</TagsInput.Root>

// Not in this example: TagsInput.TagEditInput

Examples

Form

Skills in a form: validateTag refuses anything but lowercase letters, digits and hyphens, max stops at four, onTagReject says why, and required refuses an empty submit.

TagsInputForm.tsx
import * as React from 'react'
import { Field } from '@hamur/react/field'
import { Form } from '@hamur/react/form'
import { TagsInput } from '@hamur/react/tags-input'

const MAX = 4

type Rejection = { text: string; errors: string[]; message: string | undefined }

function explain(rejection: Rejection) {
  if (rejection.message) return rejection.message
  if (rejection.errors[0] === 'duplicate') return `${rejection.text} is already added.`
  return `No more than ${MAX} skills.`
}

export default function TagsInputFormExample() {
  const [skills, setSkills] = React.useState<string[]>([])
  const [refused, setRefused] = React.useState('')
  const [submitted, setSubmitted] = React.useState('')
  return (
    <Form
      className="stack"
      onFormSubmit={(_values, details) => {
        const form = details.event.target as HTMLFormElement
        setSubmitted([...new FormData(form)].map(([key, value]) => `${key}=${value}`).join('&'))
      }}
    >
      <Field.Root name="skills" className="field">
        <Field.Label>Skills</Field.Label>
        <TagsInput.Root
          className="tags-input"
          value={skills}
          max={MAX}
          required
          validateTag={(text) =>
            /^[a-z][a-z0-9-]*$/.test(text) || 'Use lowercase letters, digits and hyphens.'
          }
          onValueChange={(next: string[]) => { setSkills(next) setRefused('') }}
          onTagReject={(rejections: Rejection[]) => setRefused(explain(rejections[0]))}
        >
          <TagsInput.List className="tags-input-list">
            {skills.map((skill) => (
              <TagsInput.Tag key={skill} value={skill} className="tags-input-tag">
                <TagsInput.TagContent className="tags-input-chip">
                  <TagsInput.TagText className="tags-input-text" />
                  <TagsInput.TagRemove className="tags-input-remove">
                    <svg className="tags-input-icon" viewBox="0 0 16 16" aria-hidden="true">
                      <path d="M4 4l8 8M12 4l-8 8" />
                    </svg>
                  </TagsInput.TagRemove>
                </TagsInput.TagContent>
              </TagsInput.Tag>
            ))}
          </TagsInput.List>
          <TagsInput.Input
            className="tags-input-input"
            placeholder="e.g. typescript"
          />
        </TagsInput.Root>
        <Field.Description className="muted">
          Up to {MAX} skills, in lowercase letters, digits and hyphens.
        </Field.Description>
        {refused ? <p className="error">{refused}</p> : null}
        <Field.Error className="error" match="valueMissing">
          Add at least one skill.
        </Field.Error>
      </Field.Root>
      <button className="button" type="submit">
        Save
      </button>
      {submitted ? <pre className="output">{submitted}</pre> : null}
    </Form>
  )
}

Editable

Recipients with editable: Enter or a double-click on a tag opens a TagEditInput; Enter saves and Escape cancels.

TagsInputEditable.tsx
import * as React from 'react'
import { TagsInput } from '@hamur/react/tags-input'

export default function TagsInputEditableExample() {
  const [emails, setEmails] = React.useState(['ada@example.com', 'grace@example.com'])
  return (
    <div className="stack">
      <TagsInput.Root
        className="tags-input"
        value={emails}
        editable
        onValueChange={(next: string[]) => setEmails(next)}
      >
        <TagsInput.List className="tags-input-list" aria-label="Recipients">
          {emails.map((email) => (
            <TagsInput.Tag key={email} value={email} className="tags-input-tag">
              <TagsInput.TagContent className="tags-input-chip">
                <TagsInput.TagText className="tags-input-text" />
                <TagsInput.TagEditInput className="tags-input-edit" />
                <TagsInput.TagRemove className="tags-input-remove">
                  <svg className="tags-input-icon" viewBox="0 0 16 16" aria-hidden="true">
                    <path d="M4 4l8 8M12 4l-8 8" />
                  </svg>
                </TagsInput.TagRemove>
              </TagsInput.TagContent>
            </TagsInput.Tag>
          ))}
        </TagsInput.List>
        <TagsInput.Input
          className="tags-input-input"
          aria-label="Add a recipient"
          placeholder="Add a recipient"
        />
      </TagsInput.Root>
      <p className="muted">
        Press Enter on a recipient, or double-click it, to edit it; Enter saves and Escape cancels.
      </p>
      <p className="muted">To: {emails.join(', ') || 'nobody'}</p>
    </div>
  )
}

Suggestions

Frameworks typed into an input that is also an autocomplete's; a picked suggestion is added through actionsRef.

TagsInputSuggestions.tsx
import * as React from 'react'
import { Autocomplete } from '@hamur/react/autocomplete'
import { TagsInput } from '@hamur/react/tags-input'

const frameworks = ['angular', 'astro', 'next', 'nuxt', 'qwik', 'react', 'solid', 'svelte', 'vue']

export default function TagsInputSuggestionsExample() {
  const actionsRef = React.useRef<{ add(text: string): boolean } | null>(null)
  const [tags, setTags] = React.useState(['react'])
  const [typed, setTyped] = React.useState('')
  return (
    <TagsInput.Root
      className="tags-input"
      value={tags}
      inputValue={typed}
      actionsRef={actionsRef}
      onValueChange={(next: string[]) => setTags(next)}
      onInputValueChange={(next: string) => setTyped(next)}
    >
      <TagsInput.List className="tags-input-list" aria-label="Frameworks">
        {tags.map((tag) => (
          <TagsInput.Tag key={tag} value={tag} className="tags-input-tag">
            <TagsInput.TagContent className="tags-input-chip">
              <TagsInput.TagText className="tags-input-text" />
              <TagsInput.TagRemove className="tags-input-remove">
                <svg className="tags-input-icon" viewBox="0 0 16 16" aria-hidden="true">
                  <path d="M4 4l8 8M12 4l-8 8" />
                </svg>
              </TagsInput.TagRemove>
            </TagsInput.TagContent>
          </TagsInput.Tag>
        ))}
      </TagsInput.List>
      <Autocomplete.Root
        items={frameworks.filter((framework) => !tags.includes(framework))}
        value={typed}
        onValueChange={(next: string, details) => {
          if (details.reason === 'item-press') {
            actionsRef.current?.add(next)
            setTyped('')
          } else {
            setTyped(next)
          }
        }}
      >
        <Autocomplete.Input
          aria-label="Add a framework"
          placeholder="Add a framework"
          render={<TagsInput.Input className="tags-input-input" />}
        />
        <Autocomplete.Portal>
          <Autocomplete.Positioner className="select-positioner" sideOffset={6}>
            <Autocomplete.Popup className="select-popup combobox-popup">
              <Autocomplete.Empty className="combobox-empty">
                No framework found.
              </Autocomplete.Empty>
              <Autocomplete.List className="select-list">
                <Autocomplete.Collection>
                  {(framework: string) => (
                    <Autocomplete.Item
                      key={framework}
                      value={framework}
                      className="select-item plain"
                    >
                      {framework}
                    </Autocomplete.Item>
                  )}
                </Autocomplete.Collection>
              </Autocomplete.List>
            </Autocomplete.Popup>
          </Autocomplete.Positioner>
        </Autocomplete.Portal>
      </Autocomplete.Root>
    </TagsInput.Root>
  )
}

API reference

31 props across 9 parts, in React syntax. className, style, render are listed once, under Shared props.

ClearTagsInput.Clear

Removes every removable tag and the Input's text, then focuses the Input. Rendered while there is something to clear. It has no default name: give it one. Renders a <button>.

Clear props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue
keepMountedWhether the component should remain mounted in the DOM when there is nothing to clear.booleanfalse

InputTagsInput.Input

The text field new tags are typed into. Place it after the List. It is described by the count of tags, rendered as its next sibling. Renders an <input>.

Input data attributes
Data attributeDescription
data-rejectedThe first error of the last refused entry, until the text changes.

ListTagsInput.List

The tags: a grid with one row per tag, a group while there are none. Place it before the Input. Renders a <div>.

RootTagsInput.Root

Groups all parts of the tags input: a text field whose value is the list of tags typed into it. with the hidden inputs, followed by the status region. Renders a <div>.

Root props
PropTypeDefault
valueThe tags.readonly string[]—
defaultValueThe tags when the component is initially rendered.readonly string[]—
onValueChangeEvent handler called when the tags change, once per gesture.((value: string[], eventDetails: TagsInputRootChangeEventDetails) => void)—
inputValueThe text of the Input.string—
defaultInputValueThe text of the Input when the component is initially rendered.string—
onInputValueChangeEvent handler called when the text of the Input changes.((inputValue: string, eventDetails: TagsInputRootInputValueChangeEventDetails) => void)—
delimitersThe strings that end a tag in typed, pasted or dropped text.readonly string[]—
addOnBlurWhether the text left in the Input is added when focus leaves the component.booleantrue
allowDuplicatesWhether a tag equal to one already there is accepted.booleanfalse
validateTagChecks an entry after it is trimmed.TagsInputValidate—
maxThe most tags the value holds.numberInfinity
onTagRejectEvent handler called with the entries of a gesture that were not added, each with its errors.((rejections: TagsInputRejection[], eventDetails: TagsInputRootRejectEventDetails) => void)—
editableWhether a tag can be edited in place, with Enter or a double-click.booleanfalse
allowMoveWhether tags can be moved by dragging, and with Ctrl/Cmd+X then Ctrl/Cmd+V.booleanfalse
canMoveWhether a move may happen.((details: GridListMoveDetails<string>) => boolean)—
loopFocusWhether the arrow keys wrap from the last tag to the first and back.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
readOnlyWhether the user should be unable to change the tags.booleanfalse
requiredWhether the user must add a tag before submitting a form.booleanfalse
nameIdentifies the field when a form is submitted: one value per tag.string—
formIdentifies the form that owns the hidden inputs.string—
actionsRefA ref to add text from the app (a picked suggestion).React.RefObject<TagsInputRootActions | null>—
labelsThe texts, in English by default.Partial<TagsInputLabels>—
Root data attributes
Data attributeDescription
data-disabledPresent when the component is disabled.
data-readonlyPresent when the component is read-only.
data-requiredPresent when a tag is required.
data-emptyPresent when the value has no tags.
data-fullPresent when the value holds max tags.
data-validPresent when the field is valid.
data-invalidPresent when the field is invalid.
data-dirtyPresent when the value has changed.
data-touchedPresent when focus has left the component.
data-filledPresent when a tag exists.
data-focusedPresent while focus is inside the component.

TagTagsInput.Tag

A tag: a row of the List with its one cell (TagsInput.TagContent). Renders a <div>.

Tag props
PropTypeDefault
childrenReact.ReactNode—
valuerequiredThe tag's text: the entry of value this tag shows.string—
labelThe name the announcements read.string—
disabledWhether the tag should ignore user interaction.booleanfalse
removableWhether the tag can be removed by a key, by TagsInput.TagRemove or by TagsInput.Clear.booleantrue
Tag data attributes
Data attributeDescription
data-highlightedPresent when focus is on the tag or inside it.
data-disabledPresent when the tag is disabled.
data-removablePresent when the tag can be removed.
data-editingPresent while the tag is being edited.
data-movingPresent while the tag is being moved.
data-drop-targetWhere a dragged tag would land relative to this one.
data-draggingPresent while the tag is being dragged.

TagContentTagsInput.TagContent

The one cell of a tag: its text, its edit input and its remove button. Renders a <div>.

TagEditInputTagsInput.TagEditInput

The text field that edits a tag in place. Rendered only while its tag is being edited, in place of TagsInput.TagText: Enter or a blur saves, Escape cancels. Renders an <input>.

TagRemoveTagsInput.TagRemove

A button that removes its tag. Named "Remove" and the tag's text. Renders a <button>.

TagRemove props
PropTypeDefault
nativeButtonWhether the component renders a native <button> element when replacing itbooleantrue

TagTextTagsInput.TagText

The text of a tag: it names the tag. Renders the tag's value when it has no children, and is not rendered while the tag is being edited. Renders a <span>.

Shared props

Every part that renders an element takes these.

Shared props
PropType
classNameCSS class applied to the element, or a function that returns a class based on the component's state.string | ((state: State) => string | undefined)
styleStyle applied to the element, or a function that returns a style object based on the component's state.React.CSSProperties | ((state: State) => React.CSSProperties | undefined)
renderAllows you to replace the component's HTML element with a different tag, or compose it with another component.React.ReactElement<unknown, string | React.JSXElementConstructor<any>> | ComponentRenderFn<HTMLProps, State>

Keyboard and accessibility

Keyboard

Keyboard
KeysDoesPart
EnterAdds the typed text and empties the Input; in an empty Input submits the formInput
,Adds the text before it and keeps the rest in the InputInput
BackspaceIn an empty Input, focuses the last tag and removes nothingInput
ArrowLeftWith the caret at the start and no selection, focuses the last tagInput
ArrowRightArrowLeftMoves to the next or previous tag, swapped in right-to-left textTag
ArrowRightOn the last tag, focuses the InputTag
ControlHomeControlEndMoves to the first or last tagTag
DeleteBackspaceRemoves the focused tag and focuses the next oneTag
EscapeFocuses the InputTag
EnterWith editable, opens the tag's text for editing, selectedTag
EnterSaves the edited text and focuses the tagTagEditInput
EscapeCancels the edit, restores the text and focuses the tagTagEditInput
TabFrom a tag, reaches its remove button, then the InputTag
EnterSpaceRemoves the tag and focuses the InputTagRemove
ControlXControlVWith allowMove, picks up the focused tag and puts it at anotherTag

Accessibility

What the parts set
PartSetsSo that
Listrole="grid" while there are tags, role="group" with data-empty while there are none; named by the Field labelthe tags are one composite with a single Tab stop, and the empty list is still named
Tagrole="row", one tab stop on the first tag; aria-disabled on a disabled tag, which arrow keys still reacha reader moves through the tags with the arrow keys and hears which cannot be changed
TagContentrole="gridcell", the row's only element childeach row has exactly one cell, as the grid pattern needs
TagTextthe row's name, the value when it has no childrena tag is read by its text, not by its remove button
TagRemovea button named "Remove {tag}", reached by Tab from its own tag only; a mousedown on it is preventedthe button says what it removes, and a press does not move focus
TagEditInputa text field holding the tag's text while it is editedan edit is typed in place and read as a field
Inputa text field with autocomplete="off", named by the Field label and described by the count ("2 tags"), then the descriptionthe count is heard on entering the field, and a refused entry sets aria-invalid
Cleara button after the Input, rendered only while there is something to clear unless keepMountedone press removes every tag and returns focus to the Input
Rootone polite role="status" region after the Rootan add, a removal, a refusal, an edit and a move are announced ("react added", "vue removed")

Focus order

  1. The List is one Tab stop, on the first tag; the arrow keys move between tags, and Tab from a tag reaches its remove button.
  2. Then the Input, where Left at the start or Backspace in an empty field moves back to the last tag.
  3. Then Clear, while there is something to clear.