f-ui
Design

Tag Selection

Mandatory rules for Status Tag, Label Tag, numeric metrics vs codes, shadcn Badge, and filter chips — no mood-based pills.

Use this page as the single source of truth when choosing a pill-shaped UI element. There is no “use whatever looks nice” path.

Examples

Status Cell

Left side picks pills by taste (Badge as status, StatusTag for provenance, LabelTag on a currency code). Right side follows the tree: StatusTag for outcome, LabelTag for Auto, plain text for CNY.

Wrong

Status cell

ApprovedAutoCNY

Badge as status · StatusTag for provenance · LabelTag on currency code

Right

Status cell

ApprovedAutoCNY

StatusTag + LabelTag for orthogonal facets; code stays plain text

import { Badge } from "@/components/ui/badge";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function TagSelectionDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Status cell
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <Badge>Approved</Badge>
            <StatusTag tone="success">Auto</StatusTag>
            <LabelTag accent="accent-1">CNY</LabelTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Badge as status · StatusTag for provenance · LabelTag on currency code
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Status cell
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Approved</StatusTag>
            <LabelTag accent="neutral">Auto</LabelTag>
            <span className="text-sm">CNY</span>
          </div>
          <p className="text-xs text-muted-foreground">
            StatusTag + LabelTag for orthogonal facets; code stays plain text
          </p>
        </div>
      }
    />
  );
}

Filter Chips

Active filters are removable conditions — not read-only field values. Left side wraps StatusTag / LabelTag with an ×; right side uses the filter-bar chip surface.

Wrong

Active filters

StatusTag / LabelTag cosplay as removable filter chips

Right

Active filters

Status is ApprovedChannel is Web

Filter bar chips remove conditions — never LabelTag or StatusTag

import { X } from "lucide-react";
import { DataListFilterTag } from "@/components/f-ui/data-list-chrome/data-list-filter-tag";
import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function TagSelectionFilterChipDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Active filters
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <button
              type="button"
              className="inline-flex items-center gap-1"
              aria-label="Remove status filter"
            >
              <StatusTag tone="success">Status is Approved</StatusTag>
              <X className="size-3 text-muted-foreground" />
            </button>
            <button
              type="button"
              className="inline-flex items-center gap-1"
              aria-label="Remove channel filter"
            >
              <LabelTag accent="accent-1">Channel is Web</LabelTag>
              <X className="size-3 text-muted-foreground" />
            </button>
          </div>
          <p className="text-xs text-muted-foreground">
            StatusTag / LabelTag cosplay as removable filter chips
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Active filters
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <DataListFilterTag
              label="Status is Approved"
              removeAriaLabel="Remove Status is Approved"
              onRemove={() => {}}
            />
            <DataListFilterTag
              label="Channel is Web"
              removeAriaLabel="Remove Channel is Web"
              onRemove={() => {}}
            />
          </div>
          <p className="text-xs text-muted-foreground">
            Filter bar chips remove conditions — never LabelTag or StatusTag
          </p>
        </div>
      }
    />
  );
}

List And Detail Parity

The same enum field must render with the same tag component on list and detail. Left side mixes LabelTag and Badge for Role; right side keeps LabelTag on both.

Wrong

Orders list · Role

Finance

Order detail · Role

Finance

Same field: LabelTag on list, Badge on detail

Right

Orders list · Role

Finance

Order detail · Role

Finance

Same field uses LabelTag on list and detail

import { Badge } from "@/components/ui/badge";
import { LabelTag } from "@/components/f-ui/label-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function TagSelectionListDetailParityDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <div className="space-y-1.5">
            <p className="text-xs text-muted-foreground">Orders list · Role</p>
            <LabelTag accent="neutral">Finance</LabelTag>
          </div>
          <div className="space-y-1.5 border-t pt-3">
            <p className="text-xs text-muted-foreground">Order detail · Role</p>
            <Badge>Finance</Badge>
          </div>
          <p className="text-xs text-muted-foreground">
            Same field: LabelTag on list, Badge on detail
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <div className="space-y-1.5">
            <p className="text-xs text-muted-foreground">Orders list · Role</p>
            <LabelTag accent="neutral">Finance</LabelTag>
          </div>
          <div className="space-y-1.5 border-t pt-3">
            <p className="text-xs text-muted-foreground">Order detail · Role</p>
            <LabelTag accent="neutral">Finance</LabelTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Same field uses LabelTag on list and detail
          </p>
        </div>
      }
    />
  );
}

Status Stack

Stacking is allowed only for orthogonal facets. Left side invents an Approved Auto mega-status plus a second StatusTag; right side keeps one StatusTag for outcome and a LabelTag for provenance.

Wrong

Status cell

Approved AutoSystem

Mega-status string + twin semantic StatusTags

Right

Status cell

ApprovedAuto

One StatusTag for outcome + LabelTag for provenance

import { LabelTag } from "@/components/f-ui/label-tag";
import { StatusTag } from "@/components/f-ui/status-tag";
import { DesignCompare } from "@/demos/_design/design-compare";

export function TagSelectionStatusStackDemo() {
  return (
    <DesignCompare
      wrong={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Status cell
          </p>
          <div className="flex flex-wrap items-center gap-1.5">
            <StatusTag tone="success">Approved Auto</StatusTag>
            <StatusTag tone="info">System</StatusTag>
          </div>
          <p className="text-xs text-muted-foreground">
            Mega-status string + twin semantic StatusTags
          </p>
        </div>
      }
      right={
        <div className="space-y-3">
          <p className="text-sm font-medium text-muted-foreground">
            Status cell
          </p>
          <span className="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
            <StatusTag tone="success">Approved</StatusTag>
            <LabelTag accent="neutral">Auto</LabelTag>
          </span>
          <p className="text-xs text-muted-foreground">
            One StatusTag for outcome + LabelTag for provenance
          </p>
        </div>
      }
    />
  );
}

Principles

  1. Read-only display of status or category → only Status Tag or Label Tag.
  2. Read-only display of currency codes or id fragments → plain text (no pill, left-align).
  3. Read-only display of comparable magnitudes (amount, qty, tax rate %, margin %) → f.number / f.currency (right-align + tabular-nums; never LabelTag).
  4. Interactive (select, remove, navigate) → only shadcn Badge inside controls, or table filter chips — never Label Tag.
  5. Numeric overlay on an icon or avatar (unread count) → Count Badge; do not fake it with Tag or Badge text.

Decision Tree

Where does this pill appear?

├─ Table cell or detail field showing a stored value?
│   ├─ Good/bad/urgent/processing semantics? → StatusTag (f.enum({ render: "status" }))
│   ├─ Category, role, channel, type?        → LabelTag (f.enum({ render: "label" }))
│   ├─ Comparable magnitude (amount, qty, tax rate %, margin %)? → f.number / f.currency (right-align)
│   └─ Currency code / id fragment? → Plain text (left-align, no pill)

├─ Inside an input (Multi-Select selected items)?
│   └─ shadcn Badge (removable chip) — do not use LabelTag

├─ Active filter with remove (×) on the filter bar?
│   └─ DataTableFilterBar chip — do not hand-roll Badge or LabelTag

└─ Unread count on Bell / avatar?
    └─ Count Badge (numeric overlay) — never Status Tag / Label Tag / shadcn Badge

Hard Ban — Mood Pills

Developers must not pick Status Tag vs Label Tag by visual taste. Every table/detail enum goes through the tree above. Reviewers reject PRs that:

  • Put success / warning / destructive tones on category fields
  • Wrap currency codes in Label Tag / Status Tag
  • Wrap tax rate / margin % in Label Tag (use numeric column instead)
  • Left-align tax rate / margin % / amount as if they were prose
  • Use shadcn Badge for read-only status or category cells
  • Use Status Tag for values that never mean good / bad / urgent / in-flight

Scenario Matrix

ScenarioComponentNotes
Order status, payment, priorityStatus Tagf.enum({ render: "status" }) with tone
In progress with spinnerStatus Tagicon: "spin" on that variant only
Completed / failed with iconStatus Tagicon: "auto" — opt-in, not table default
Offer / contract lifecycle (Approved, Effective)Status TagOutcome / window state
Category, channel, role, typeLabel Tagf.enum({ render: "label" }); default neutral
Dense table columnsStatus / Label TagOmit icon unless the state needs emphasis
Multi-Select selected optionshadcn BadgeFull pill, removable — component-internal
Filter bar active conditionFilter bar chipPart of list filter surfaces
Detail page role (read-only)Label TagSame as users table — not shadcn Badge
Marketing / link pillshadcn BadgeasChild + link OK; not for table enums
Sidebar / bell unread 3Count BadgeNumeric overlay — not Tag; host control owns the accessible name
Currency ISO code (CNY/USD)Plain text (left)Code / identifier — not a magnitude
Tax rate / Margin %f.number (right + tabular-nums)Comparable metric; put % in the header (Tax rate %), not a LabelTag

Status Tag vs Label Tag vs Plain Text

QuestionYes →No →
Should the user care if the value is good, bad, urgent, or in-flight?Status TagContinue
Is it a category / role / channel / type that needs scan chips?Label Tag (no semantic tones)Continue
Is it a magnitude users compare across rows (amount, qty, tax %, margin %)?f.number / f.currency (right-align)Continue
Is it a currency code, unit label, or id fragment?Plain text (left-align)

Schema (via Field Types):

  • f.enum({ render: "status" }) variants: { label, tone, icon? }tone required.
  • f.enum({ render: "label" }) variants: { label, accent? } — no tone; optional accent: "auto".
  • Measures / tax rate % / margin % → f.number (right-align); currency amountsf.currency; currency codesf.text.
  • Do not fake percentages as LabelTag pills.

See Field Types and Table for schema-driven status and label rendering.

Label Tag vs shadcn Badge

Both can look like small capsules. They are not interchangeable.

Label Tagshadcn Badge
JobShow a field valuePart of a control (selection, link)
Shaperounded-md + visible borderrounded-4xl pill
InteractionStaticOften removable / clickable
ColorNon-semantic accent or neutralprimary, secondary, outline, …
Import@/components/f-ui/label-tag@/components/ui/badge

Visual rule of thumb: bordered, squarer, in a table cell → data tag. Full pill inside an input with × → shadcn Badge.

Do not display the same field two ways (e.g. Label Tag in the table and Badge on the detail page for role).

Multiple Pills In One Status Cell

Stacking more than one pill under a Status column is allowed when each pill answers a different question — industry default, not a smell.

PrecedentSplit
SAP Fiori Object Status vs Object MarkerBusiness lifecycle (Object Status) ≠ technical / marker state (Object Marker). Rare dual statuses: primary emphasized, secondary muted.
GitHub Issues / PRsState pill ≠ Label pills on the same row.
Ant Design TableMany Tags in a Tags column = categories; Status demos stay one semantic Tag.

When To Stack

PrimarySecondaryExample
Status Tag (outcome / lifecycle)Label Tag (provenance / category)Approved + Auto when decisionSource=system
Status TagOmit secondaryHuman-approved line — no Manual chip (noise)

Aligns with Domain Status Vs Derived View: outcome ⊥ provenance (do not invent Approved Auto as one StatusTag).

Hard Rules

  1. One primary StatusTag for business outcome — leftmost.
  2. Secondary = LabelTag (or future marker), not a second semantic StatusTag fighting for attention.
  3. Default at most one secondary chip in the Status cell; more than one needs an explicit product reason (SAP markers cap ~3 technical markers).
  4. Prefer a shared cell helper (compose once) — do not scatter ad-hoc double tags.
  5. Do not use StatusTag icon="auto" to mean “system decided” — that API is an opt-in status glyph, not provenance. Use LabelTag Auto (or dedicated column) for provenance.
  6. Category piles (bug, p1, channel, …) belong in a Labels / category column, not under Status.

Layout

<span className="inline-flex max-w-full items-center gap-1.5 whitespace-nowrap">
  <StatusTag tone="success">Approved</StatusTag>
  <LabelTag accent="neutral">Auto</LabelTag>
</span>

Sort / filter by the lifecycle field; “Auto approved” filters = outcome ∩ provenance (not a third enum).

What We Do Not Ship

ComponentWhy
f-ui Badge for status/labelsOverlaps Status Tag / Label Tag; confuses variant vs tone
Label Tag for filtersUse filter bar or Multi-Select
shadcn Badge for table status/category columnsUse f.enum({ render: "status" }) / f.enum({ render: "label" })
Any pill for currency code or tax rateCodes = plain text; tax rate = numeric column

Code Review Checklist

  • Table/detail enum uses f.enum({ render: "status" }) or f.enum({ render: "label" }), not legacy badge.
  • No import { Badge } from "@/components/ui/badge" for read-only status, category, or role.
  • Label fields do not use semantic status tones.
  • Currency codes are plain text; tax rate / margin % use f.number (right-align), not Tag.
  • Processing states use icon: "spin" only where the value is truly in-flight.
  • Same field uses the same tag component on list and detail views.
  • Status cell stacks StatusTag + LabelTag only for orthogonal facets (e.g. outcome + Auto); no twin semantic StatusTags; no Approved Auto mega-status.
  • StatusTag icon="auto" is not used as a substitute for provenance LabelTag Auto.

On this page