Lesson 20 / 25
Badges and Tags
The specification of the badge and the tag, building the number badge's accessible name, checking the contrast of status colors, and computing distinguishability without color information by relative luminance.
Contents
Empty and error states were notifications covering an entire screen. The same information is often carried on a much smaller surface: a one-word mark next to a row, a number in the corner of a button, a tag reporting a record’s type.
A small surface harbors two traps together. First, information staying in color alone; second, the accessible name consisting of a number alone. This lesson ties both to a measurable check.
What It Solves, When Not to Use It
A badge is a small status or number mark attached to another element. It has two kinds: a status badge reports a record’s condition (“Overdue”), a number badge reports a quantity (a notification count).
A tag is a mark showing a record’s classification, and it is often removable. The tags showing applied criteria in the filter panel are this kind.
The distinction is functional: a badge describes the element it is attached to and is not interactive on its own; a tag is an object on its own and can be removed.
Situations where it should not be used:
- A badge cannot be the sole carrier of critical information. If “Overdue” exists only in the badge, that information does not exist for a user who cannot see the badge; the same information must also exist in the record’s detail view.
- A badge is not made clickable. A clickable mark is a button and has to follow the button’s contract; a button styled to look like a badge also misses the target size threshold.
- A tag is not shown as removable without a removal control. A tag carrying an “x” mark that cannot be removed with the keyboard is misleading.
Native Element First, ARIA Second
The badge has no counterpart set aside in markup; the setup rests on a name and text.
| Part | Source |
|---|---|
| Status badge | An element carrying text; the meaning lives in the text itself, no extra role needed |
| Number badge | The number is written into the name of the control it is attached to |
| The number changing | A polite region next to the control announces it, not the badge |
| Removable tag | The text is one element, the removal button is a separate button |
| The removal button’s name | “Remove tag X” — includes the tag’s text |
The number badge’s setup is where mistakes happen most. A badge reading “3”, placed as a separate node next to the button, sits in the accessibility tree as a number without context. The correct setup folds the number into the button’s name: “Notifications, 3 unread”. The visual badge is also hidden from the tree, because its information already lives in the name.
Abbreviated numbers require a separate rule. A badge reading “99+” on screen must carry the real number in its name; the visual abbreviation is a space constraint, not an information constraint.
Keyboard Contract
A badge does not take focus and holds no place in the tab order. A tag’s removal button, on the other hand, is a full button:
| Key | Behavior |
|---|---|
| Tab | Reaches the removal button in sequence |
| Enter / space | Removes the tag |
| Focus after removal | Moves to the next tag; to the tag list’s container if the last tag was removed |
The last item shows another form of the return-point rule from the Modal Dialogs lesson: where focus goes when a focused element is removed is part of the specification. When it is not defined, focus falls to the start of the document and the user loses their place in the filter panel.
When a tag is removed, the result count changes; this change is announced from a polite region.
Color Is Not a Sufficient Channel
A status badge’s color is a shortcut, and the limit of that shortcut can be computed. Two measurements are required: the badge’s own contrast, and the badges’ distinguishability against each other. The second shows what is left once color information cannot be used.
// badge.mjs — badge color contrast, luminance collision, and the second-channel check const split = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255); const linear = (k) => (k <= 0.04045 ? k / 12.92 : ((k + 0.055) / 1.055) ** 2.4); const luminance = (hex) => { const [r, g, b] = split(hex).map(linear); return 0.2126 * r + 0.7152 * g + 0.0722 * b; }; const ratio = (a, b) => { const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p); return (x + 0.05) / (y + 0.05); }; const SURFACE = "#F4F4F5"; // the record card's surface const BADGE = [ { name: "available to borrow", bg: "#2E7D32", text: "#FFFFFF", label: "Available", icon: true }, { name: "reserved", bg: "#B26A00", text: "#FFFFFF", label: "Reserved", icon: true }, { name: "return overdue", bg: "#C62828", text: "#FFFFFF", label: "Overdue", icon: true }, { name: "lost", bg: "#616161", text: "#FFFFFF", label: "Lost", icon: false }, { name: "new record", bg: "#1565C0", text: "#FFFFFF", label: null, icon: false }, ]; console.log("badge background relative luminance text/bg bg/surface 1.4.3 1.4.11"); for (const r of BADGE) { const tb = ratio(r.text, r.bg), bs = ratio(r.bg, SURFACE); console.log( `${r.name.padEnd(19)} ${r.bg} ${luminance(r.bg).toFixed(4).padStart(15)} ` + `${(tb.toFixed(2) + ":1").padStart(9)} ${(bs.toFixed(2) + ":1").padStart(11)} ` + `${(tb >= 4.5 ? "passes" : "FAILS").padStart(6)} ${(bs >= 3 ? "passes" : "FAILS").padStart(7)}`, ); } // --- Luminance collision: can the badges be distinguished when color information is unavailable? // If two badges' contrast ratio against each other is close to 1, they become identical in the colorless channel. console.log("\ncontrast ratio between badges (distinguishability without color information)"); console.log(" " + BADGE.map((r) => r.name.slice(0, 8).padStart(9)).join("")); const colliding = []; for (const a of BADGE) { let row = a.name.padEnd(21); for (const b of BADGE) { const o = ratio(a.bg, b.bg); row += o.toFixed(2).padStart(9); if (a !== b && o < 1.5 && BADGE.indexOf(a) < BADGE.indexOf(b)) colliding.push([a.name, b.name, o]); } console.log(row); } console.log("\nindistinguishable pairs in the colorless channel (threshold 1.5:1):"); for (const [a, b, o] of colliding) console.log(` ${a} / ${b}: ${o.toFixed(2)}:1`); if (!colliding.length) console.log(" none"); // --- Second-channel check ------------------------------------------------------- // Status must be carried on at least one channel besides color: text or icon. console.log("\nbadge text icon second channel"); for (const r of BADGE) { const channel = [r.label ? "text" : null, r.icon ? "icon" : null].filter(Boolean); console.log(`${r.name.padEnd(19)} ${(r.label ? "yes" : "—").padStart(4)} ${(r.icon ? "yes" : "—").padStart(4)} ` + `${channel.length ? channel.join(" + ") : "NONE — status is color-only"}`); } // --- The number badge's accessible name ----------------------------------------- const NAME_EXAMPLE = [ { shown: "3", name: "3" }, { shown: "3", name: "Notifications" }, { shown: "3", name: "Notifications, 3 unread" }, { shown: "99+", name: "Notifications, 128 unread" }, ]; console.log("\nshown accessible name number context sufficient"); for (const a of NAME_EXAMPLE) { const hasNumber = /\d/.test(a.name); const hasContext = /[A-Za-z]{3,}/.test(a.name); console.log(`${a.shown.padEnd(8)} ${a.name.padEnd(29)} ${(hasNumber ? "yes" : "no").padStart(6)} ` + `${(hasContext ? "yes" : "no").padStart(7)} ${hasNumber && hasContext ? "yes" : "NO"}`); }
badge background relative luminance text/bg bg/surface 1.4.3 1.4.11
available to borrow #2E7D32 0.1548 5.13:1 4.66:1 passes passes
reserved #B26A00 0.1977 4.24:1 3.86:1 FAILS passes
return overdue #C62828 0.1368 5.62:1 5.11:1 passes passes
lost #616161 0.1195 6.19:1 5.63:1 passes passes
new record #1565C0 0.1327 5.75:1 5.23:1 passes passes
contrast ratio between badges (distinguishability without color information)
availabl reserved return o lost new reco
available to borrow 1.00 1.21 1.10 1.21 1.12
reserved 1.21 1.00 1.33 1.46 1.36
return overdue 1.10 1.33 1.00 1.10 1.02
lost 1.21 1.46 1.10 1.00 1.08
new record 1.12 1.36 1.02 1.08 1.00
indistinguishable pairs in the colorless channel (threshold 1.5:1):
available to borrow / reserved: 1.21:1
available to borrow / return overdue: 1.10:1
available to borrow / lost: 1.21:1
available to borrow / new record: 1.12:1
reserved / return overdue: 1.33:1
reserved / lost: 1.46:1
reserved / new record: 1.36:1
return overdue / lost: 1.10:1
return overdue / new record: 1.02:1
lost / new record: 1.08:1
badge text icon second channel
available to borrow yes yes text + icon
reserved yes yes text + icon
return overdue yes yes text + icon
lost yes — text
new record — — NONE — status is color-only
shown accessible name number context sufficient
3 3 yes no NO
3 Notifications no yes NO
3 Notifications, 3 unread yes yes yes
99+ Notifications, 128 unread yes yes yes
The first table runs the expected check and finds a defect: the orange badge’s ratio against white text is 4.24:1, below the text threshold. The same color meets the non-text threshold (3:1). This is a trap that shows up often in a color system — orange sits at a luminance between red and green and is not enough to carry white text. The fix is to darken the color or to write the text in a dark shade.
The second table is the lesson’s main finding. All ten pairs among the five badge colors are indistinguishable from each other without color information. The worst pair, at 1.02:1, is “return overdue” and “new record”; red and blue fall to the same shade of gray in the colorless channel because their relative luminances are nearly equal. Even the best pair stays at 1.46:1 and does not clear the chosen 1.5:1 threshold.
This result is not a design mistake. The color palette was chosen by role, as in the Semantic Colors lesson, and each color meets the thresholds against its own surface. What is proven is something more fundamental: status colors cannot be chosen so that they are distinguishable from each other. Separating all five roles with enough of a luminance gap between them would push the colors to extremes too light and too dark to use. This is why the 1.4.1 criterion does not forbid using color as an information carrier; it forbids using it as the only carrier.
The third table checks the second channel. Three of the five badges carry both text and an icon, one carries text only, one carries neither. The “new record” badge is a colored dot alone, and its information exists in no channel besides color — the check catches this directly. The “lost” badge, carrying text only, is sufficient; an icon is not required, one second channel is enough.
The last table tests the number badge’s name. The name “3” carries the number but does not say three of what; the name “Notifications” carries context but drops the number. A name that combines the two passes both checks. The fourth row shows the abbreviation rule: while the screen reads “99+”, the name holds the real number.
Measurable Constraints
1.4.1 Use of Color. Color cannot be the only visual means of conveying information. The check’s counterpart is the second-channel table above.
1.4.3 Contrast (Minimum). Badge text meets 4.5:1 against its background; 3:1 at sizes counted as large text. Because badge text is small, 4.5:1 is what is required in practice.
1.4.11 Non-Text Contrast. The badge’s background must meet 3:1 against the surface it sits on; if the badge’s edge is indistinct, the badge itself cannot be seen.
2.5.8 Target Size (Minimum). A tag’s removal button is expanded to a 24 x 24 pixel area. When tags are arranged side by side, the distance between two removal buttons must not break this threshold either.
1.4.4 Resize Text. When badge text doubles in size, the badge must grow, not clip the text. A fixed-width, circular number badge cuts off the number at this threshold.
4.1.3 Status Messages. A change in a badge’s number is a status message and is announced from a polite region, not from the badge itself.
Common Mistakes and How to Recognize Them
The badge is read as a separate node. The “3” next to the button is announced as a number without context. How to recognize it: check whether the number is present in the button’s name field.
Status is shown as a colored dot alone. How to recognize it: imagining the screen without color is not enough, it has to be measured — compute the pairwise contrast ratios of the status colors and count the pairs below 1.5:1.
The tag removal button’s name is “remove”. The same name appears on all five tags, and which tag will be removed is unclear. How to recognize it: count how many times the same name repeats on the links-and-buttons plane; this is the button counterpart of the “same text, different target” defect from the Screen Reader Experience lesson.
The number badge stays even at zero. An empty badge keeps information that does not exist on screen. How to recognize it: check whether the badge is removed when the number resets to zero.
Summary
- A badge describes the element it is attached to and is not interactive; a tag is an object on its own, and its removal button is a full button.
- A number badge’s information is written into the name of the control it is attached to; the visual badge is hidden from the tree, and the on-screen abbreviation is spelled out in full in the name.
- Badge text must meet 4.5:1, and the badge’s background must meet 3:1 against the surface; the orange background missed the text threshold at 4.24:1 in the measurement.
- Status colors cannot be chosen to be distinguishable from each other: all ten pairs among the five colors fall below the 1.5:1 threshold in the colorless channel, and the closest pair is 1.02:1.
- This is why every status badge needs a second channel — text or icon; having one is enough, having both is not required.
- Where focus goes when a tag is removed, and how the result count is announced, are part of the specification.
Next Step
In every one of the seven patterns in this topic, the final decision came down to text. A notification’s duration was computed from the text’s word count, an error message’s sufficiency was tied to whether it wrote out a recovery path, and a badge’s second channel was most often a single word. Text is not the last field of a component’s specification; it is the material carrying the specification itself.
The next topic turns this material into a system. It first ties interface text to checkable rules — tone of voice, length, and terminology consistency become measurable. It then takes up error message writing, localization constraints, right-to-left layout, and regional field requirements. The first lesson begins by counting how many separate words the interface uses for the same concept.
To keep your progress and take notes, Log in
My notes
Log in to take notes.