Tag

Developer Experience (DX)

How developers use the Tag, with one typed API that takes links and routers, fires one event and keeps no state of its own.

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:

NameKindWhat it means
labelPropThe text, when it's plain text, like the Button's
sizePropOne of the Input's five sizes, from xs to xl
hrefPropThe component is a link, and this is where it goes
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
removeLabelPropA remove button's name, built from the label, like the Select's
defaultSlotThe main content, between the component's tags
leadingSlotAn icon before the label

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

  • removable adds the remove button, and remove is the event it fires, as Atlassian's isRemovable and Fluent's dismissible tags do. We made it a prop, rather than guessing from whether you listen for remove, so every framework has the same API, including Astro and plain HTML, which don't have listeners in their markup.
  • The remove slot is the remove button's icon, named after the part it fills, so the slot and the data-slot it renders have the same name.

The Tag also leaves a few props out, on purpose:

  • There's no color or variant. 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 selected or pressed. 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 onClick on 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.

PropTypeDefaultDescription
labelstringThe 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
hrefstringMakes the label a link
removablebooleanfalseAdds a remove button, which fires remove
removeLabel(label: string) => string(label) => `Remove ${label}` The remove button's name
disabledbooleanfalseDims the tag, drops its remove button and turns its link off
readonlybooleanfalseDrops the remove button and keeps the tag's look. A link still works
EventPayloadDescription
removeMouseEventThe remove button was pressed. It never fires while disabled or read-only, and the tag doesn't remove itself
SlotDescription
defaultThe label, when it's more than plain text, or your router's link, which the tag styles like its own
leadingAn icon before the label, hidden from assistive technologies
removeThe 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.

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, rel or download, like one to another site, goes in the default slot too, with those attributes on it.
  • Keep label when 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.
  • disabled turns off the link the Tag renders, but it can't take the href off 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:

WhatHow you control it
Whether it's thereYour list of tags, which you change when remove fires
Linkhref, or a link in the default slot
Removableremovable, and disabled or readonly to take the button away
HoverThe browser, from the pointer over the link or the remove button
FocusThe link's or the remove button's own focus(), through a ref to the tag

DX Checklist

RuleLevelScopeRequirementBasisCheck
tag/shared-vocabularyMustComponentIt 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 removeOpen ComponentsReview
tag/typedMustComponentProps, slots and the event are typed, with unions rather than string, so size only takes the five sizesOpen ComponentsType check
tag/statelessShouldComponentremove fires, and the tag stays until whatever owns it takes it awayOpen ComponentsUnit test
tag/root-elementMustComponentThe root element is the tag itself, so attributes, listeners and refs land on itOpen ComponentsUnit test
tag/slotsShouldComponentEvery part has a slot or tokens, and the remove button keeps its name when its icon is slottedOpen ComponentsUnit test
tag/dev-warningsShouldComponentIt warns in development when a tag is removable without a labelOpen ComponentsUnit test
Copyright © 2026