Badge
betav0.1.0Notification marker attached to another element: a small count, or a dot when the count itself does not matter. Replaces MUI `Badge` (27 render sites across OpenCTI and OpenAEV, all of them hand-styled through `sx`). Purely presentational and never interactive — no Radix primitive exists for a badge, and none is needed: there is no keyboard model, no state and nothing to focus. The dot is DERIVED from an absent `content`, and can also be FORCED while a count exists, for “something is waiting, do not show how much” — the accessible name stays complete either way. Boundary with `Chip`: a Chip labels (its own text carries the whole meaning and reads on its own line), a Badge marks (remove the element it sits on and it means nothing). The default tone is `"error"` (red) — changed from `"brand"` on 2026-08-13 because red is what products reach for most often; every other tone stays available and is chosen case by case.
Variants
Examples
{/* No tone: the default is "error" (red) since 2026-08-13. */}
<Badge content={unreadCount} max={99}>
<Icon name="bell" size={24} />
</Badge>{/* The previous default, still available — now an explicit choice. */}
<Badge content={unreadCount} tone="brand" max={99}>
<Icon name="bell" size={24} />
</Badge>{/* Dot: no count to show. accessibleText is required — a bare coloured
dot would carry its meaning by colour alone (WCAG 1.4.1). */}
<Badge tone="error" accessibleText={t('Unread notifications')}>
<Icon name="bell" size={24} />
</Badge>{/* Forced dot: there IS a count, but the exact number is not shown.
It is still announced in full. */}
<Badge content={runningCount} dot tone="brand" circularAnchor>
<IconButton icon={<Icon name="layers" />} aria-label={t('Bulk operations')} />
</Badge>{/* Standalone: no children, no wrapper, no absolute positioning. */}
<Badge tone={healthy ? 'success' : 'error'} accessibleText={t(healthy ? 'Healthy' : 'Degraded')} />Props
| Prop | Type |
|---|---|
content | number | string — what the badge displays. Omit it to render the dot form. A number is capped by `max`; a string is never capped. |
tone | BadgeTone ("brand" | "error" | "success" | "neutral") — semantic colour. Default "error" (red) since 2026-08-13; it was "brand" before, so a badge rendered without this prop now paints red. The axis itself is unchanged: pass tone="brand" to keep the previous appearance. |
dot | boolean — force the dot rendering even though `content` is set (“something is waiting, do not show how much”). The value is still announced in full. Default false. |
max | number — overflow cap for a numeric `content`: above it, renders `${max}+`. NO default (uncapped). A negative or non-finite value is treated as uncapped. |
showZero | boolean — render the badge when `content` is 0. Default false. |
invisible | boolean — do not render the badge at all (the node is not mounted, so nothing is left in the accessibility tree). Default false. |
accessibleText | string | false — what the ANCHOR is described as (the wrapper wires `aria-describedby` from the anchor to the badge). Omitted: the resolved value. A string: that string instead, and the visible digits are hidden from assistive technology. `false`: no description is wired and the badge is aria-hidden. REQUIRED when there is no `content`. |
circularAnchor | boolean — tuck the badge onto a round anchor instead of a square one (14% inset, MUI's own circular formula). Default false. |
bareAnchor | "md" | "sm" | false — declares that the anchor paints no resting surface: a tertiary IconButton, or any control styled through iconButtonVariants({ priority: "tertiary" }). At "md" the badge tucks 8px in on both axes so it stays visually attached to the glyph, which a filled or outlined anchor gets for free from its own painted box (Figma node 7611:19982). At "sm" the design file draws tertiary like the other treatments, so it resolves to the unchanged geometry — pass it anyway, so a later sm change lands in the library and not in your call site. Only "md" takes precedence over circularAnchor (the one combination where both ask for an inset); "sm" leaves circularAnchor in effect, because a round anchor still needs its tuck whether or not it paints a resting surface. Default false. |
children | ReactNode — the element the badge marks. Omit it to render the badge standalone, in normal flow, with no wrapper and no absolute positioning. |
Accessibility
The badge DESCRIBES its anchor. When `children` is given, the wrapper clones the single child and gives it `aria-describedby` pointing at the badge element, so the anchor's own accessible NAME is untouched and the count is its accessible DESCRIPTION — proven by `toHaveAccessibleName`/`toHaveAccessibleDescription` on a real <button>, not by reading the DOM. This deliberately replaces relying on the accname algorithm sweeping the badge's text out of the anchor's subtree: that only works for an anchor named by its contents, and 7 of the 27 inventory sites put the badge inside an `IconButton` with an `aria-label`, where the label wins and the count would have been announced by nobody. `accessibleText` replaces the announced string and HIDES the visible digits from assistive technology, so the description is the consumer's phrase alone and never both. `accessibleText={false}` takes the badge out of the accessibility tree AND adds no `aria-describedby`, for a consumer who already named the anchor. An anchor that already had an `aria-describedby` keeps it — the badge appends. No English phrase is ever synthesised: a bilingual product must not be handed a hardcoded English string. If `children` is not a single React element there is nothing to clone onto: the badge still renders, the description is not wired, and a dev-only `console.warn` says so (AGENTS.md "Prop contract violations"). A dot with no `accessibleText` is a TYPE ERROR, and the same dev warning catches the untyped call sites the compiler never sees, because a bare coloured dot carries its meaning by colour alone (1.4.1). Forcing the dot while `content` is set is exempt: the value is still described, so the name never degrades with the visual. No live region: 12 of the 27 inventory sites are polling counters that would chatter, and 12 are dots with nothing to announce; a consumer who needs one passes `aria-live` through, so the default is proposed and never imposed. No disabled state: no badge in either product is disabled (MUI's Badge has no such prop), and a marker that carries information is not something one deactivates. Never interactive: no `onClick`, no `role`, no `tabIndex`, no focus ring — a clickable counter is the consumer's own <button> wrapped by this component. Default tone: moving it from `brand` to `error` on 2026-08-13 added NO pair and removed none — both tones were already declared and measured above, and the change only picks a different one of the four. It is a visual change, not an accessibility one: the error counter (8.81:1 dark / 6.27:1 light) and the error dot (5.18:1 / 5.92:1 against the reference surface) both clear their thresholds by a wider margin than the brand pair they replace as the default. No `alert` tone was added: the `feedback-alert` family is amber (--yellow-400, the same value in both modes), and its dot measures 1.54:1 against every light surface, below WCAG 1.4.11's 3:1 floor — it could not have shipped.