Skip to content

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

DisplayReact

Tag Group

A set of tags that are shown, removed or selected, with no text field.

Rendered by React 19.3.1

Loading React…
import * as React from 'react'
import { TagGroup } from '@hamur/react/tag-group'

const initial = ['bug', 'docs', 'performance', 'good first issue']

export default function TagGroupExample() {
  const [labels, setLabels] = React.useState(initial)
  return (
    <div className="stack">
      <TagGroup.Root
        className="tag-group"
        aria-label="Labels"
        onRemove={(values: string[]) => setLabels((current) => current.filter((label) => !values.includes(label)))}
      >
        {labels.map((label) => (
          <TagGroup.Tag key={label} value={label} className="tag-group-tag">
            <TagGroup.TagContent className="tag-group-chip">
              <TagGroup.TagText className="tag-group-text" />
              <TagGroup.TagRemove className="tag-group-remove">
                <svg className="tags-input-icon" viewBox="0 0 16 16" aria-hidden="true">
                  <path d="M4 4l8 8M12 4l-8 8" />
                </svg>
              </TagGroup.TagRemove>
            </TagGroup.TagContent>
          </TagGroup.Tag>
        ))}
        <TagGroup.Empty className="tag-group-empty">
          No labels.
        </TagGroup.Empty>
      </TagGroup.Root>
      <div>
        <button type="button" className="button" onClick={() => setLabels(initial)}>
          Reset labels
        </button>
      </div>
    </div>
  )
}
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:

<TagGroup.Root>
  <TagGroup.Tag>
    <TagGroup.TagContent>
      <TagGroup.TagText />
      <TagGroup.TagRemove />
    </TagGroup.TagContent>
  </TagGroup.Tag>
  <TagGroup.Empty />
</TagGroup.Root>

Examples

Filters

Status filters with selectable and multiple: a press or Space toggles one, a selected tag shows a check, and Archived is disabled.

TagGroupFilters.tsx
import * as React from 'react'
import { TagGroup } from '@hamur/react/tag-group'

const statuses = [
  { value: 'open', label: 'Open' },
  { value: 'in-review', label: 'In review' },
  { value: 'merged', label: 'Merged' },
  { value: 'closed', label: 'Closed' },
  { value: 'archived', label: 'Archived', disabled: true },
]

export default function TagGroupFiltersExample() {
  const [selected, setSelected] = React.useState<string[]>(['open'])
  return (
    <div className="stack">
      <TagGroup.Root
        className="tag-group"
        aria-label="Status"
        selectable
        multiple
        value={selected}
        onValueChange={(next: string[]) => setSelected(next)}
      >
        {statuses.map((status) => (
          <TagGroup.Tag
            key={status.value}
            value={status.value}
            disabled={status.disabled}
            className="tag-group-tag"
          >
            <TagGroup.TagContent className="tag-group-chip">
              <TagGroup.TagText className="tag-group-text">
                {status.label}
              </TagGroup.TagText>
            </TagGroup.TagContent>
          </TagGroup.Tag>
        ))}
      </TagGroup.Root>
      <p className="muted">Showing: {selected.join(', ') || 'everything'}</p>
    </div>
  )
}

API reference

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

EmptyTagGroup.Empty

Content shown while the group has no tags. Renders nothing otherwise. It has no ARIA role. Renders a <div>.

RootTagGroup.Root

Groups the tags the app shows: a grid with one row per tag, a group while there are none, with removal through onRemove and Grid List's selection., followed by the status region. Renders a <div>.

Root props
PropTypeDefault
valueThe selected value.TagGroupInputValue<Value, Multiple> | null—
defaultValueThe uncontrolled selected value when the group is initially rendered.TagGroupInputValue<Value, Multiple> | null—
onValueChangeEvent handler called when the selection changes, once per gesture.((value: TagGroupOutputValue<Value, Multiple>, eventDetails: TagGroupRootChangeEventDetails) => void)—
selectableWhether tags can be selected.booleanfalse
multipleWhether several tags can be selected.Multiplefalse
disallowEmptySelectionWhether the last selected tag of a multiple group cannot be deselected.booleanfalse
selectOnPressWhether a press on a selectable tag selects it.booleantrue
isItemEqualToValueCustom comparison logic used to determine if a tag value matches the current value.((itemValue: Value, value: Value) => boolean)—
onRemoveEvent handler called with the values of the tags to remove, in document order: a removal((values: Value[], eventDetails: TagGroupRootRemoveEventDetails) => void)—
allowMoveWhether tags can be moved by dragging and with Ctrl/Cmd+X then Ctrl/Cmd+V.booleanfalse
canMoveWhether a move may happen.((details: GridListMoveDetails<Value>) => boolean)—
onItemMoveCalled with a move.((details: GridListMoveDetails<Value>, eventDetails: TagGroupRootItemMoveEventDetails) => void)—
loopFocusWhether the arrow keys wrap from the last tag to the first and back.booleanfalse
disabledWhether the component should ignore user interaction.booleanfalse
labelsThe texts of the status region.Partial<TagGroupLabels>—
Root data attributes
Data attributeDescription
data-disabledPresent when the group is disabled.
data-emptyPresent when the group has no tags.
data-selectablePresent when tags can be selected.
data-multiplePresent when several tags can be selected.
data-movingPresent while a move is pending.

TagTagGroup.Tag

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

Tag props
PropTypeDefault
childrenReact.ReactNode—
valuerequiredA unique value that identifies this tag.any—
labelThe name the announcements and typeahead read.string—
disabledWhether the tag should ignore user interaction.booleanfalse
removableWhether the tag can be removed by a key or by TagGroup.TagRemove.booleantrue
selectableWhether the tag can be selected, in a group that is selectable.booleantrue
onActionCalled when the tag is activated: Enter, or a press on a tag that does not select.((eventDetails: GridListActionDetails) => void)—
Tag data attributes
Data attributeDescription
data-selectedPresent when the tag is selected.
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-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.

TagContentTagGroup.TagContent

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

TagRemoveTagGroup.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

TagTextTagGroup.TagText

The text of a tag: it names the tag. Renders String(value) when it has no children. 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
ArrowRightArrowLeftMoves to the next or previous tag and stops at the endsTag
ControlHomeControlEndMoves to the first or last tagTag
Moves to the next tag whose text starts with the typed charactersTag
SpaceWith selectable, selects or clears the focused tagTag
ControlAWith multiple, selects every enabled tagTag
EnterCalls the tag's onActionTag
DeleteBackspaceCalls onRemove with the focused tag, then focuses the next oneTag
TabFrom a tag, reaches its remove button, then leaves the groupTag
EnterRemoves the tag and focuses the next oneTagRemove
ControlXControlVWith allowMove, picks up the focused tag and calls onItemMove at anotherTag

Accessibility

What the parts set
PartSetsSo that
Rootrole="grid" with aria-multiselectable when multiple; role="group" with data-empty while there are no tagsthe tags are one composite with one Tab stop; a development build warns without a name
Tagrole="row", one tab stop on the first enabled tag; aria-selected when selectable; aria-disabled when disableda reader hears which tags are selected and 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, String(value) when it has no childrena tag is read by its text, not by its remove button
TagRemovea button named "Remove {tag}"; aria-disabled without onRemove; a mousedown on it is preventedthe button says what it removes, and a press does not move focus
Emptyrendered only while there are no tagsan empty group says so instead of showing nothing

Focus order

  1. The group is one Tab stop, on the first enabled tag; the arrow keys and typeahead move between tags.
  2. Tab from a tag reaches its remove button, then leaves the group.
  3. A disabled Root has no Tab stop.