Developer Experience (DX)
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:
| Name | Kind | What it means |
|---|---|---|
label | Prop | The text that names it, like the Button's, here the list's name |
size | Prop | One of the Input's five sizes, from xs to xl |
disabled | Prop | It can't be used, and drops out of the tab order |
readonly | Prop | People can read it but not change it, like the Input's and the Select's |
removable | Prop | Tags get a remove button, like the Tag's |
removeLabel | Prop | A remove button's name, built from the label, like the Tag's and the Select's |
removedLabel | Prop | What's announced once a tag has gone, like the Select's |
remove | Event | A remove button was pressed, like the Tag's |
leading | Slot | An icon before the label |
The rest of the Tag Group's names are its own, and each one says what it is:
tagsis the list it shows, each with avalue, the key thatremovehands back, and alabel, along with the Tag'shref,disabledandreadonlyfor that tag alone. It's calledtagsrather thanoptionsoritems, since they aren't options to pick from, as the Select's are.emptyLabeland theemptyslot are what it shows with no tags, as React Aria'srenderEmptyStateis.fallbackFocusis the element that takes focus when no tag with a remove button follows the one removed, like the field the tags came from.- The
tagslot 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
selectionModeorselected. 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
onKeyDownforBackspaceorDelete. A tag's remove button is the only way to remove it, and it works withEnter,Spaceand a click. - There's no
removeslot 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.
| Prop | Type | Default | Description |
|---|---|---|---|
tags | TagGroupItem[] | The tags, in order. The group shows them and never changes the list itself | |
label | string | The list's name, like "Labels". Or name it with aria-labelledby | |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Every tag's size |
removable | boolean | false | Gives every tag a remove button, which fires remove |
disabled | boolean | false | Disables every tag |
readonly | boolean | false | Drops 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 |
emptyLabel | string | "No tags" | What it says with no tags |
fallbackFocus | HTMLElement | null | (() => HTMLElement | null | undefined) | Where focus goes when no tag with a remove button follows the one removed |
| Field | Type | Description |
|---|---|---|
value | string | The tag's key, unique in the group, and what remove hands back |
label | string | The tag's text, which also names its remove button |
href | string | Makes the tag's label a link |
disabled | boolean | Disables this tag alone |
readonly | boolean | Drops this tag's remove button |
| Event | Payload | Description |
|---|---|---|
remove | TagGroupItem | A tag's remove button was pressed. The group keeps the tag until tags drops it |
| Slot | Props | Description |
|---|---|---|
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 |
empty | What 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.
Links and routers
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 theleadingslot gives each one an icon. - A Select with
multipleshows 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 asfallbackFocus. - 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:
| What | How you control it |
|---|---|
| Which tags it shows | tags, which you change when remove fires |
| Removable | removable, and disabled or readonly to take the buttons away, for the group or one tag |
| Where focus goes | The next remove button, then fallbackFocus, which you name |
| What's announced | removedLabel, once a tag whose button was pressed has gone |
| Focus | Each tag's link or remove button, which you can focus through a ref to the group |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tag-group/shared-vocabulary | Must | Component | It 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 fallbackFocus | Open Components | Review |
tag-group/typed | Must | Component | Props, slots, the event and TagGroupItem are typed, with unions rather than string, so size only takes the five sizes | Open Components | Type check |
tag-group/controlled-removal | Should | Component | remove fires with the tag, and the group keeps it until tags drops it, moving no focus and announcing nothing while it stays | Open Components | Unit test |
tag-group/root-element | Must | Component | The 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 list | Open Components | Unit test |
tag-group/slots | Should | Component | tag 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 name | Open Components | Unit test |
tag-group/dev-warnings | Should | Component | It warns in development when the list has no name | Open Components | Unit test |