Skip to content

Tag

A pale option chip for select values such as stage, tier and lifecycle, in ten categorical hues.

Status
Stable
Level
Atom
Category
Data display
Adoption
Not used yet
import { Tag } from "@oration/canon/components/tag";
packages/canon/src/components/tag.tsx
CompanyStageLifecyclePayment method
Northwind FreightNegotiationOpportunityACH
Halcyon LogisticsProposalQualifiedVirtual card
Orchard Street MarketClosed wonCustomerCheck
Brightline PackagingDiscoveryProspectACH
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 dot for 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 ten categorical hues belong to select-option values (stage, lifecycle, tier, list and attribute options) and to identity tints on avatars and monogram tiles. They never color-code sections, navigation, status or charts.

The Label-Beside-Color Rule

Status is never color alone. A tag's text is the value and its hue is a recognition aid, so a tag must read correctly in grayscale. A run result or a health verdict is a Status label, not a green or red tag.

Anatomy#

Negotiation
  1. Container. 20px tall with 6px side padding and 8px corners, filled with the option's pale --tag-* color. max-w-full lets it shrink in a cell.
  2. Dot. Optional, with dot. A 6px circle in the text color at 80% opacity, marked aria-hidden. Used for stage values.
  3. Label. Label type, 12px at weight 500, in the same hue's deep --tag-*-fg color. 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.

Othergray
Logisticsblue
Manufacturingindigo
Softwareviolet
Healthcarepink
Energyred
Constructionorange
Retailamber
Food and beveragegreen
Hospitalityteal
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.

DiscoveryEvaluationProposalNegotiationClosed wonClosed lost
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.

Halcyon LogisticsProposal

AP automation rollout

ProposalEnterprise
import { 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.

Stage
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.

StageEvaluationchanges toProposal
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.

Supplier support queue skills
Remittance questionsW-9 follow-upSpanishVendor onboardingPayment run exceptions
TermsNet 45 with 2% early-pay discount
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#

On the plane
Negotiation
In a well
Negotiation
In a selected row
Negotiation
Truncated
Hospitality and travel
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>    );}
States
StateTreatment
RestThe only state a tag draws. It has no hover, focus, pressed or disabled look because it is not a control.
TruncatedWhen its container is narrower than the value, the label ends in an ellipsis. Pass the full value as title.
ChangedIn 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 rowThe tag keeps its fill on the 6% indigo row tint; nothing about the tag changes.
EditableDrawn 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 native color attribute, which the color prop replaces.
  • color defaults to gray. 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 truncate span, so long values end in an ellipsis instead of wrapping or pushing the row wider.
  • dot adds 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#

Sync to NetSuitePassed
Import W-9sFailed
Do. Show run and sync results with a Status label: a dot and a word that read in grayscale.
Sync to NetSuitePassed
Import W-9sFailed
Don't. Use green and red tags for Passed and Failed. It spends categorical hues on status and reads like a select value.
Remittance questionsW-9 follow-upSpanishVendor onboarding
Do. Keep free-form values gray and reserve hues for options that were configured with one.
Remittance questionsW-9 follow-upSpanishVendor onboarding
Don't. Give free-form values random hues. The colors suggest categories that don't exist and change from screen to screen.
Stage
Do. Make the value editable by wrapping the tag in a Select trigger, so the menu, keyboard and focus ring come from the control.
StageProposal
Don't. Put a remove icon inside the tag. It looks like a filter chip, has no hit area of its own and isn't reachable by keyboard.

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-label such as Stage for Northwind Freight.

Design tokens#

Design tokens
TokenUsed for
--tag-gray … --tag-tealPale fills for gray, blue, indigo, violet, pink, red, orange, amber, green and teal
--tag-gray-fg … --tag-teal-fgThe deep same-hue text for each fill; the dot inherits it
--radius-md8px corners at both sizes
text-xs12px Label type at the default size
text-1313px label at the large size
--foregroundThe 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).

Props of Tag
PropTypeDefaultDescription
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.
dotbooleanfalseAdds a 6px dot in the text color before the label, for stage values.
childrenReactNodeNo defaultThe value. Rendered inside a truncating span, so it never wraps.
titlestringNo defaultThe full value, shown on hover when the tag truncates.
classNamestringNo defaultMerged after the variant classes.

tagVariants

The class recipe behind Tag, from cva.

Props of tagVariants
PropTypeDefaultDescription
colorTagColor | 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.