Tag
A pale option chip for select values such as stage, tier and lifecycle, in ten categorical hues.
| Company | Stage | Lifecycle | Payment method |
|---|---|---|---|
| Northwind Freight | Negotiation | Opportunity | ACH |
| Halcyon Logistics | Proposal | Qualified | Virtual card |
| Orchard Street Market | Closed won | Customer | Check |
| Brightline Packaging | Discovery | Prospect | ACH |
import { Tag, type TagColor } from "@oration/canon/components/tag";export function Hero() { const rows: { company: string; stage: { label: string; color: TagColor }; lifecycle: { label: string; color: TagColor }; method: string; }[] = [ { company: "Northwind Freight", stage: { label: "Negotiation", color: "amber" }, lifecycle: { label: "Opportunity", color: "violet" }, method: "ACH", }, { company: "Halcyon Logistics", stage: { label: "Proposal", color: "violet" }, lifecycle: { label: "Qualified", color: "blue" }, method: "Virtual card", }, { company: "Orchard Street Market", stage: { label: "Closed won", color: "green" }, lifecycle: { label: "Customer", color: "green" }, method: "Check", }, { company: "Brightline Packaging", stage: { label: "Discovery", color: "gray" }, lifecycle: { label: "Prospect", color: "gray" }, method: "ACH", }, ]; return ( <div className="w-full max-w-2xl overflow-x-auto rounded-xl bg-card shadow-border"> <table className="w-full min-w-[34rem] border-collapse text-left text-13"> <thead> <tr className="h-8 border-b border-border text-muted-foreground"> <th scope="col" className="px-3 font-medium"> Company </th> <th scope="col" className="px-2 font-medium"> Stage </th> <th scope="col" className="px-2 font-medium"> Lifecycle </th> <th scope="col" className="px-2 font-medium"> Payment method </th> </tr> </thead> <tbody> {rows.map((row) => ( <tr key={row.company} className="h-9 border-b border-border last:border-b-0 hover:bg-surface" > <td className="px-3 font-medium text-foreground"> {row.company} </td> <td className="px-2"> <Tag color={row.stage.color} dot> {row.stage.label} </Tag> </td> <td className="px-2"> <Tag color={row.lifecycle.color}> {row.lifecycle.label} </Tag> </td> <td className="px-2"> <Tag>{row.method}</Tag> </td> </tr> ))} </tbody> </table> </div> );}Usage#
Tag shows a value: the option chosen in a select field, such as a deal's stage, a company's lifecycle or a supplier's payment method. It is a pale 20px chip in one of ten categorical hues, and the hue belongs to the option, so Negotiation is amber on every screen. A tag is a value, not a control and not a status: it has no hover, no focus and no remove button, and its hue never means good or bad. The usual mistake is reaching for a green or red tag to show a run result or health; that job belongs to Status label.
When to use
- To show a select-option value in a table cell, a record header or an attribute rail: Negotiation, Customer, ACH.
- With
dotfor pipeline stages, so the value reads as a position in a sequence. - At
size="lg"in record headers and roomy panels, where the tag stands next to 14px or larger text. - Inside a Select trigger and its items, so an option looks the same in the menu as on the record.
- To show a changed value in a proposal: the old tag struck through, an arrow, then the new tag.
- In gray for free-form values with no option config, such as a queue's skills.
When not to use
- For run, sync or health state such as Running, Failed or At risk. Status is a dot beside a label. Use Status label
- For a count or a qualifier attached to a control, such as 3 unread or Beta. Use Badge
- For an active filter that someone can remove. Use Filter chip
- For values someone types and removes in place, such as remittance email recipients. Use Tag input
- For a template variable such as
{{supplier_name}}. Use Variable chip - To color-code sections, navigation, owners or chart series. Hues are for option values only.
The Option Hue Rule
The Label-Beside-Color Rule
Anatomy#
- Container. 20px tall with 6px side padding and 8px corners, filled with the option's pale
--tag-*color.max-w-fulllets it shrink in a cell. - Dot. Optional, with
dot. A 6px circle in the text color at 80% opacity, markedaria-hidden. Used for stage values. - Label. Label type, 12px at weight 500, in the same hue's deep
--tag-*-fgcolor. One line; it truncates with an ellipsis.
Examples#
Ten hues
The categorical family, shown as the options of one Industry field. Each option picks its hue once in the field's config, and every tag for that option reads it from there.
import { TAG_COLORS, Tag, type TagColor } from "@oration/canon/components/tag";export function Hues() { const industry: Record<TagColor, string> = { gray: "Other", blue: "Logistics", indigo: "Manufacturing", violet: "Software", pink: "Healthcare", red: "Energy", orange: "Construction", amber: "Retail", green: "Food and beverage", teal: "Hospitality", }; return ( <div className="grid w-full max-w-2xl grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-5"> {TAG_COLORS.map((color) => ( <div key={color} className="flex min-w-0 flex-col items-start gap-1.5" > <Tag color={color}>{industry[color]}</Tag> <span className="font-mono text-xs text-muted-foreground"> {color} </span> </div> ))} </div> );}Stages with a dot
Pass dot for pipeline stages. The 6px dot is the text color at 80%, so the value reads as a position in a sequence without adding a second color.
import { Tag, type TagColor } from "@oration/canon/components/tag";export function Stages() { const stages: { label: string; color: TagColor }[] = [ { label: "Discovery", color: "gray" }, { label: "Evaluation", color: "blue" }, { label: "Proposal", color: "violet" }, { label: "Negotiation", color: "amber" }, { label: "Closed won", color: "green" }, { label: "Closed lost", color: "red" }, ]; return ( <> {stages.map((stage) => ( <Tag key={stage.label} color={stage.color} dot> {stage.label} </Tag> ))} </> );}Sizes
The default 20px tag sits in 13px rows and cells. size="lg" is 24px at 13px for record headers and roomy panels, where it stands next to 14px or larger text.
AP automation rollout
ProposalEnterpriseimport { Tag } from "@oration/canon/components/tag";export function Sizes() { return ( <div className="mx-auto flex w-full max-w-xl flex-col gap-6"> <div className="flex items-center justify-between gap-4 border-b border-border pb-3 text-13"> <span className="font-medium text-foreground"> Halcyon Logistics </span> <Tag color="violet" dot> Proposal </Tag> </div> <div className="flex flex-wrap items-center gap-2"> <h3 className="text-xl leading-7 font-semibold tracking-[-0.015em] text-foreground"> AP automation rollout </h3> {/* Restate the size and hue text until lg keeps them (see Known gaps). */} <Tag color="violet" size="lg" dot className="text-[13px] text-(--tag-violet-fg)" > Proposal </Tag> <Tag color="indigo" size="lg" className="text-[13px] text-(--tag-indigo-fg)" > Enterprise </Tag> </div> </div> );}In a select
The editable version of a tag is a Select whose trigger and items render the same Tag, so the option looks identical in the menu and on the record. The tag itself never becomes the control.
import { Select, SelectContent, SelectItem, SelectTrigger } from "@oration/canon/components/select";import { Tag, type TagColor } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function InSelect() { const options: { value: string; color: TagColor }[] = [ { value: "Discovery", color: "gray" }, { value: "Evaluation", color: "blue" }, { value: "Proposal", color: "violet" }, { value: "Negotiation", color: "amber" }, { value: "Closed won", color: "green" }, { value: "Closed lost", color: "red" }, ]; const [stage, setStage] = React.useState("Negotiation"); const current = options.find((option) => option.value === stage); return ( <div className="flex items-center gap-3 text-13"> <span className="text-muted-foreground">Stage</span> <Select value={stage} onValueChange={(value) => { if (typeof value !== "string") return; setStage(value); toast.add({ title: `Northwind Freight moved to ${value}`, description: "Maya Okafor is notified of stage changes.", }); }} > <SelectTrigger size="sm" aria-label="Stage for Northwind Freight" className="h-7 gap-1 border-transparent bg-transparent px-1 shadow-none hover:bg-muted dark:bg-transparent dark:hover:bg-muted" > <Tag color={current?.color ?? "gray"} dot> {stage} </Tag> </SelectTrigger> <SelectContent alignItemWithTrigger={false} align="start" className="min-w-44" > {options.map((option) => ( <SelectItem key={option.value} value={option.value}> <Tag color={option.color} dot> {option.value} </Tag> </SelectItem> ))} </SelectContent> </Select> </div> );}Changed value
When a proposal changes a select field, show the old tag struck through, an arrow, then the new tag, in a tint well. The arrow is hidden from screen readers and replaced by the words changes to.
Move Halcyon Logistics to Proposal
Tomás Ferreira sent pricing on Friday and the controller replied with redlines.
import { Button } from "@oration/canon/components/button";import { StatusLabel } from "@oration/canon/components/status-dot";import { Tag } from "@oration/canon/components/tag";import { toast } from "@oration/canon/components/toast";import { ArrowRightIcon } from "lucide-react";import * as React from "react";export function ChangedValue() { const [status, setStatus] = React.useState<"pending" | "approved">( "pending", ); return ( <div className="flex w-full max-w-md flex-col gap-3 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-1"> <p className="text-sm font-medium text-foreground"> Move Halcyon Logistics to Proposal </p> <p className="text-13 text-muted-foreground"> Tomás Ferreira sent pricing on Friday and the controller replied with redlines. </p> </div> <div className="flex flex-wrap items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> <span className="text-muted-foreground">Stage</span> <Tag color="blue" dot className="line-through decoration-foreground/40" > Evaluation </Tag> <ArrowRightIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <span className="sr-only">changes to</span> <Tag color="violet" dot> Proposal </Tag> </div> <div className="flex items-center justify-end gap-2"> {status === "approved" ? ( <StatusLabel tone="success" className="mr-auto"> Approved </StatusLabel> ) : null} <Button type="button" variant="ghost" size="sm" onClick={() => { setStatus("pending"); toast.add({ title: "Skipped the stage change" }); }} > Skip </Button> <Button type="button" size="sm" disabled={status === "approved"} onClick={() => { setStatus("approved"); toast.add({ type: "success", title: "Halcyon Logistics moved to Proposal", }); }} > Approve </Button> </div> </div> );}Free-form values and truncation
Values with no option config, such as a queue's skills, stay gray. A tag never wraps: in a narrow cell it truncates with an ellipsis, so pass the full value as title.
import { Tag } from "@oration/canon/components/tag";export function FreeForm() { const skills = [ "Remittance questions", "W-9 follow-up", "Spanish", "Vendor onboarding", "Payment run exceptions", ]; return ( <div className="flex w-full max-w-md flex-col gap-4"> <div className="flex flex-col gap-2"> <span className="text-13 font-medium text-foreground"> Supplier support queue skills </span> <div className="flex flex-wrap gap-1"> {skills.map((skill) => ( <Tag key={skill}>{skill}</Tag> ))} </div> </div> <div className="flex w-48 items-center gap-2 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> <span className="shrink-0 text-muted-foreground">Terms</span> <Tag title="Net 45 with 2% early-pay discount" className="min-w-0" > Net 45 with 2% early-pay discount </Tag> </div> </div> );}States#
import { Tag } from "@oration/canon/components/tag";export function Contexts() { return ( <div className="grid w-full grid-cols-2 gap-6 sm:grid-cols-4"> <div className="flex min-w-0 flex-col gap-3"> <span className="text-xs text-muted-foreground"> On the plane </span> <div className="flex h-9 items-center"> <Tag color="amber" dot> Negotiation </Tag> </div> </div> <div className="flex min-w-0 flex-col gap-3"> <span className="text-xs text-muted-foreground">In a well</span> <div className="flex h-9 items-center rounded-[10px] bg-muted/70 px-3"> <Tag color="amber" dot> Negotiation </Tag> </div> </div> <div className="flex min-w-0 flex-col gap-3"> <span className="text-xs text-muted-foreground"> In a selected row </span> <div className="flex h-9 items-center bg-[color-mix(in_oklch,var(--primary)_6%,var(--background))] px-2"> <Tag color="amber" dot> Negotiation </Tag> </div> </div> <div className="flex min-w-0 flex-col gap-3"> <span className="text-xs text-muted-foreground">Truncated</span> <div className="flex h-9 w-28 items-center"> <Tag color="teal" title="Hospitality and travel"> Hospitality and travel </Tag> </div> </div> </div> );}| State | Treatment |
|---|---|
| Rest | The only state a tag draws. It has no hover, focus, pressed or disabled look because it is not a control. |
| Truncated | When its container is narrower than the value, the label ends in an ellipsis. Pass the full value as title. |
| Changed | In a proposal or history entry, the old tag takes line-through with a 40% ink decoration, followed by an arrow and the new tag. |
| In a selected row | The tag keeps its fill on the 6% indigo row tint; nothing about the tag changes. |
| Editable | Drawn by the Select trigger around the tag: a Well Gray hover fill, the focus ring and a chevron. The tag inside stays at rest. |
Behavior#
- Tag renders a plain
<span data-slot="tag">. It accepts every span attribute except the nativecolorattribute, which thecolorprop replaces. colordefaults togray. Store the hue on the option (for example{ value: "Negotiation", color: "amber" }) and pass it through, rather than choosing a hue at the call site.- Children render inside an inner
truncatespan, so long values end in an ellipsis instead of wrapping or pushing the row wider. dotadds the 6px circle before the label. It inherits the text color, so it always matches the hue.tagVariants({ color, size })returns the class string, for the rare element that must look like a tag but can't be a Tag.- Tags don't animate. A changed value is shown with the struck-through pattern, not a color transition.
Do and don't#
Content#
- Option labels are short nouns or noun phrases in sentence case: Closed won, not Closed Won or WON.
- Keep them to one to three words and put the distinguishing word first: Net 30, not Payment terms net 30. Tags truncate, they don't wrap.
- No punctuation, emoji or counts inside a tag. A count is a Badge; a note belongs beside the tag.
- Name options for what they are, not how they look: Enterprise, never Purple tier.
- For a changed value, let the arrow say it. Don't write from and to into the tags.
Accessibility#
- Tag has no role and reads as its text. The dot is
aria-hidden, so meaning never depends on color. - Every hue pairs a pale fill with a deep text color of the same hue. Measured in this page's previews, the ten pairs sit between 5.5:1 and 7.4:1 in light and above 7.3:1 in dark, so the label passes 4.5:1 on its own fill.
- When a tag truncates, pass the full value as
title, and make sure the full value is also shown in the record or its Select. - In a changed value, hide the arrow with
aria-hidden="true"and add<span className="sr-only">changes to</span>, so it reads as Stage Evaluation changes to Proposal. - Tags are not focusable. If a value can be edited, the Select trigger around it is the focus stop and carries an
aria-labelsuch as Stage for Northwind Freight.
Design tokens#
| Token | Used for |
|---|---|
--tag-gray … --tag-teal | Pale fills for gray, blue, indigo, violet, pink, red, orange, amber, green and teal |
--tag-gray-fg … --tag-teal-fg | The deep same-hue text for each fill; the dot inherits it |
--radius-md | 8px corners at both sizes |
text-xs | 12px Label type at the default size |
text-13 | 13px label at the large size |
--foreground | The 40% line-through decoration on a changed value |
API reference#
Tag
A read-only option value. Also exported: tagVariants, TAG_COLORS (the ten hues, in order) and the TagColor type.
Other props spread onto <span> (without the native "color" attribute).
| Prop | Type | Default | Description |
|---|---|---|---|
color | "gray" | "blue" | "indigo" | "violet" | "pink" | "red" | "orange" | "amber" | "green" | "teal" | null | "gray" | The option's hue. Read it from the option config, never pick it per screen. |
size | "default" | "lg" | null | "default" | 20px at 12px, or 24px at 13px with 8px side padding for record headers. |
dot | boolean | false | Adds a 6px dot in the text color before the label, for stage values. |
children | ReactNode | No default | The value. Rendered inside a truncating span, so it never wraps. |
title | string | No default | The full value, shown on hover when the tag truncates. |
className | string | No default | Merged after the variant classes. |
tagVariants
The class recipe behind Tag, from cva.
| Prop | Type | Default | Description |
|---|---|---|---|
color | TagColor | null | "gray" | Hue. |
size | "default" | "lg" | null | "default" | Height and type size. |
TAG_COLORS
readonly ["gray", "blue", "indigo", "violet", "pink", "red", "orange", "amber", "green", "teal"]. Use it to build hue pickers for option config.
No props of its own.
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
size="lg" renders 12px Graphite Ink text on the pale fill. The cn inside packages/canon reads the variant's text-13 as a text color, so it drops the hue's text-(--tag-*-fg) and leaves text-xs. Until it is fixed, restate both at the call site, as the Sizes example does: className="text-[13px] text-(--tag-violet-fg)". The agent skills list uses lg without the workaround.
Icons passed as children render at 24px. The [&>svg]:size-3 rule targets direct children, but children sit inside the inner truncate span, so a lucide icon keeps its default size and is clipped by the 20px box. The Shared and Private tags in ticket views and the Suggested tag in the macro list show it.
Tag is also used for qualifiers that aren't option values, such as an indigo Default tag on ticket statuses and a gray System tag on presence statuses. Those are Badge's job, and the indigo one spends a categorical hue outside The Option Hue Rule.
The approval card marks the change arrow with aria-label on a bare svg, which most screen readers skip. The example here uses aria-hidden and sr-only text instead.