Tag Group

Developer Experience (DX)

How developers use the Tag Group, with one typed API that takes a list of tags, fires one event and keeps no tags of its own.

Next, let's look at how developers use the Tag Group. It has one API, with the same names as the Tag and every other component, it's typed end to end, and it keeps no tags of its own: you give it a list, it fires remove, and you decide what goes.

import { TagGroup, type TagGroupItem } from "@/components/TagGroup";

export function IssueLabels({ labels, onRemove }: { labels: TagGroupItem[]; onRemove: (label: TagGroupItem) => void }) {
  return <TagGroup label="Labels" tags={labels} removable onRemove={onRemove} />;
}

Consistent

Once you've learned the Tag's API, you already know most of the Tag Group's, since it passes most of its props straight to each tag. These names mean the same thing everywhere in Open Components:

NameKindWhat it means
labelPropThe text that names it, like the Button's, here the list's name
sizePropOne of the Input's five sizes, from xs to xl
disabledPropIt can't be used, and drops out of the tab order
readonlyPropPeople can read it but not change it, like the Input's and the Select's
removablePropTags get a remove button, like the Tag's
removeLabelPropA remove button's name, built from the label, like the Tag's and the Select's
removedLabelPropWhat's announced once a tag has gone, like the Select's
removeEventA remove button was pressed, like the Tag's
leadingSlotAn icon before the label

The rest of the Tag Group's names are its own, and each one says what it is:

  • tags is the list it shows, each with a value, the key that remove hands back, and a label, along with the Tag's href, disabled and readonly for that tag alone. It's called tags rather than options or items, since they aren't options to pick from, as the Select's are.
  • emptyLabel and the empty slot are what it shows with no tags, as React Aria's renderEmptyState is.
  • fallbackFocus is the element that takes focus when no tag with a remove button follows the one removed, like the field the tags came from.
  • The tag slot is a tag's label, for links that go through your router, and it goes to each Tag's default slot.

The Tag Group also leaves a few things out, on purpose:

  • There's no selectionMode or selected. A filter that toggles is a toggle button, and picking from a set is a Checkbox Group, as Can the tags in a group be selected? explains.
  • There's no orientation. Tags wrap onto as many rows as they need, as text does.
  • There's no onKeyDown for Backspace or Delete. A tag's remove button is the only way to remove it, and it works with Enter, Space and a click.
  • There's no remove slot for the remove buttons' icon. It's the Tag's, so a class that restyles the Tag restyles it in every group.

Type-safe

Props, slots and the event are typed, so your editor can autocomplete them and your type checker (tsc, vue-tsc, svelte-check, ngc or astro check) catches mistakes like size="md ", a tag without a value or a fallbackFocus that isn't an element.

PropTypeDefaultDescription
tagsTagGroupItem[]The tags, in order. The group shows them and never changes the list itself
labelstringThe list's name, like "Labels". Or name it with aria-labelledby
size"xs" | "sm" | "md" | "lg" | "xl""md"Every tag's size
removablebooleanfalseGives every tag a remove button, which fires remove
disabledbooleanfalseDisables every tag
readonlybooleanfalseDrops every remove button. Links still work
removeLabel(label: string) => string(label) => `Remove ${label}` A remove button's name
removedLabel(label: string) => string(label) => `${label} removed` What's announced once a tag has gone
emptyLabelstring"No tags"What it says with no tags
fallbackFocusHTMLElement | null | (() => HTMLElement | null | undefined)Where focus goes when no tag with a remove button follows the one removed
FieldTypeDescription
valuestringThe tag's key, unique in the group, and what remove hands back
labelstringThe tag's text, which also names its remove button
hrefstringMakes the tag's label a link
disabledbooleanDisables this tag alone
readonlybooleanDrops this tag's remove button
EventPayloadDescription
removeTagGroupItemA tag's remove button was pressed. The group keeps the tag until tags drops it
SlotPropsDescription
tag{ tag }A tag's label, when it's more than plain text, or your router's link
leading{ tag }An icon before a tag's label, hidden from assistive technologies
emptyWhat it shows with no tags, in place of emptyLabel

We also export TagGroupItem and TagGroupProps, which come in handy when your tags come from data. Vanilla has no types, so it has no example here:

import type { TagGroupItem } from "@/components/TagGroup";

const labels: TagGroupItem[] = issue.labels.map((label) => ({ value: label.id, label: label.name, href: `/labels/${label.id}` }));

Composable

The root element is the group itself, a <div>, so everything you pass lands directly on it, like classes, styles, id, dir, lang and event listeners, and a ref to the Tag Group reaches it. The one exception is the list's name: aria-label, aria-labelledby and aria-describedby land on the <ul>, since that's what they name.

Each tag with an href renders a plain <a>, as the Tag does. For links that go through your router, put your router's link in the tag slot, which each Tag puts in its label and styles the same way:

import { Link } from "react-router";

<TagGroup label="Labels" tags={labels} removable onRemove={remove} tag={({ tag }) => <Link to={`/labels/${tag.value}`}>{tag.label}</Link>} />

Keep the tag's label the same as what the slot shows, since each remove button's name is built from it. Since the Tag Group doesn't know anything about your router, it works with any of them.

Moving focus out of the group

fallbackFocus takes an element, or a function that returns one when it's needed, which is handy when the element renders after the group. Each framework has its own way to reach an element: a ref in React, Vue and Solid, a bound ref in Svelte (bind:ref on a component, or bind:this on an element) and a template variable in Angular, as the example under Move focus when a tag goes shows. Astro and Vanilla render HTML, so their script moves focus itself.

With other components

  • Each tag is the Tag, so a class that sets the Tag's variables, like --tag--background, restyles every tag in the group, and the leading slot gives each one an icon.
  • A Select with multiple shows its values as tags of its own today, with the same keyboard model, and gives its control as the place focus goes after the last one. It'll switch to the Tag Group in a follow-up, with its control as fallbackFocus.
  • In a Card, a tag group goes in the body or the footer, like a project's topics.

Controllable

The Tag Group doesn't keep any tags of its own. Everything about it is a prop, an attribute or an element that you control:

WhatHow you control it
Which tags it showstags, which you change when remove fires
Removableremovable, and disabled or readonly to take the buttons away, for the group or one tag
Where focus goesThe next remove button, then fallbackFocus, which you name
What's announcedremovedLabel, once a tag whose button was pressed has gone
FocusEach tag's link or remove button, which you can focus through a ref to the group

DX Checklist

RuleLevelScopeRequirementBasisCheck
tag-group/shared-vocabularyMustComponentIt takes label, size, disabled, readonly, removable, removeLabel and leading like the Tag, removedLabel like the Select, fires remove like the Tag, and names its own props after what they do: tags, emptyLabel and fallbackFocusOpen ComponentsReview
tag-group/typedMustComponentProps, slots, the event and TagGroupItem are typed, with unions rather than string, so size only takes the five sizesOpen ComponentsType check
tag-group/controlled-removalShouldComponentremove fires with the tag, and the group keeps it until tags drops it, moving no focus and announcing nothing while it staysOpen ComponentsUnit test
tag-group/root-elementMustComponentThe root element is the group itself, so attributes, listeners and refs land on it, while aria-label, aria-labelledby and aria-describedby land on the listOpen ComponentsUnit test
tag-group/slotsShouldComponenttag and leading go to each Tag's default and leading slots, and empty replaces the empty state's text, while every remove button keeps its nameOpen ComponentsUnit test
tag-group/dev-warningsShouldComponentIt warns in development when the list has no nameOpen ComponentsUnit test
Copyright © 2026