Developer Experience (DX)
Next, let's look at how developers use the Tag. It has one API, with the same names as every other component, it's typed end to end, and it keeps no state of its own: it fires remove, and you decide what goes.
import { Tag } from "@/components/Tag";
export function IssueLabels({ labels, onRemove }: { labels: Label[]; onRemove: (label: Label) => void }) {
return (
<ul className="labels" aria-label="Labels">
{labels.map((label) => (
<li key={label.id}>
<Tag label={label.name} href={`/labels/${label.id}`} removable onRemove={() => onRemove(label)} />
</li>
))}
</ul>
);
}
Consistent
Once you've learned the Button's and the Select's APIs, you already know most of the Tag's. These names mean the same thing everywhere in Open Components:
| Name | Kind | What it means |
|---|---|---|
label | Prop | The text, when it's plain text, like the Button's |
size | Prop | One of the Input's five sizes, from xs to xl |
href | Prop | The component is a link, and this is where it goes |
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 |
removeLabel | Prop | A remove button's name, built from the label, like the Select's |
default | Slot | The main content, between the component's tags |
leading | Slot | An icon before the label |
The rest of the Tag's names are its own, and each one says what it is:
removableadds the remove button, andremoveis the event it fires, as Atlassian'sisRemovableand Fluent's dismissible tags do. We made it a prop, rather than guessing from whether you listen forremove, so every framework has the same API, including Astro and plain HTML, which don't have listeners in their markup.- The
removeslot is the remove button's icon, named after the part it fills, so the slot and thedata-slotit renders have the same name.
The Tag also leaves a few props out, on purpose:
- There's no
colororvariant. A colour that means something is a status, which is the Badge's job, and tags coloured by category get their colours from a class (see Container and label). - There's no
selectedorpressed. A tag that toggles is a toggle button, which the Button covers, and picking from a set is a Checkbox Group, as Can a tag be selected? explains. - There's no
as. A tag is a<span>, and in a list you put it in an<li>, as the example at the top of this page does. The Tag Group marks up a group of tags that way for you. - There's no
onClickon the tag. Its link and its remove button are what people press, and each has its own.
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 " or a removeLabel that doesn't return a string.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | The text, when it's plain text, and what the remove button's name is built from. The default slot takes precedence | |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | The size, with the Select's tag heights |
href | string | Makes the label a link | |
removable | boolean | false | Adds a remove button, which fires remove |
removeLabel | (label: string) => string | (label) => `Remove ${label}` | The remove button's name |
disabled | boolean | false | Dims the tag, drops its remove button and turns its link off |
readonly | boolean | false | Drops the remove button and keeps the tag's look. A link still works |
| Event | Payload | Description |
|---|---|---|
remove | MouseEvent | The remove button was pressed. It never fires while disabled or read-only, and the tag doesn't remove itself |
| Slot | Description |
|---|---|
default | The label, when it's more than plain text, or your router's link, which the tag styles like its own |
leading | An icon before the label, hidden from assistive technologies |
remove | The remove button's icon. The button and its name stay |
We also export TagProps, which comes in handy for wrappers and data-driven pages:
import type { TagProps } from "@/components/Tag";
const bug: TagProps = { label: "Bug", href: "/labels/bug", removable: true };
Composable
The root element is the tag itself, a <span>, so everything you pass lands directly on it. That includes classes, styles, id, dir, lang, ARIA attributes and event listeners. A ref to the Tag reaches that same element, so there's no wrapper to reach through.
Links and routers
href renders a plain <a> in the label. For links that go through your router, put your router's link in the default slot instead, and the Tag styles it the same way, with the same border, hit area and focus ring, since it styles any link in its label. Routers that handle every link on the page only need the href:
import { Link } from "react-router";
<Tag label="Bug" removable onRemove={remove}>
<Link to="/labels/bug">Bug</Link>
</Tag>
Keep these in mind:
- Attributes you pass to the Tag land on its root, not on its link. So a link that needs
target,relordownload, like one to another site, goes in the default slot too, with those attributes on it. - Keep
labelwhen the default slot holds a link, since the remove button's name is built from it, and keep the two the same, so the link and its remove button say the same thing. disabledturns off the link the Tag renders, but it can't take thehrefoff a link you rendered. For a disabled tag, render plain text in the slot instead of your link.
Since the Tag doesn't know anything about your router, it works with any of them.
With other components
- In a Select with
multiple, each value is shown as a tag, with the same sizes as the Tag, and each of its tokens has a Tag token with the same job (see Tokens). The Select will switch to the Tag Group in a follow-up. - In a Card, tags go in the body or the footer, like a project's topics.
- A row of tags is a list, so put them in a
<ul>with each tag in an<li>, and name the list, like "Labels", so screen readers say what the tags are and how many there are. The Tag Group does that for you, along with the spacing between tags and moving focus when one goes.
Controllable
The Tag doesn't keep any state of its own. Everything about it is a prop, an attribute or an element that you control:
| What | How you control it |
|---|---|
| Whether it's there | Your list of tags, which you change when remove fires |
| Link | href, or a link in the default slot |
| Removable | removable, and disabled or readonly to take the button away |
| Hover | The browser, from the pointer over the link or the remove button |
| Focus | The link's or the remove button's own focus(), through a ref to the tag |
DX Checklist
| Rule | Level | Scope | Requirement | Basis | Check |
|---|---|---|---|---|---|
tag/shared-vocabulary | Must | Component | It takes label, size, href, disabled and leading like the Button, readonly and removeLabel like the Select, and names its own prop and event after what they do: removable and remove | Open Components | Review |
tag/typed | Must | Component | Props, slots and the event are typed, with unions rather than string, so size only takes the five sizes | Open Components | Type check |
tag/stateless | Should | Component | remove fires, and the tag stays until whatever owns it takes it away | Open Components | Unit test |
tag/root-element | Must | Component | The root element is the tag itself, so attributes, listeners and refs land on it | Open Components | Unit test |
tag/slots | Should | Component | Every part has a slot or tokens, and the remove button keeps its name when its icon is slotted | Open Components | Unit test |
tag/dev-warnings | Should | Component | It warns in development when a tag is removable without a label | Open Components | Unit test |