Badge
The chip that annotates something else: a status beside a row, a count on a
tab, a category under a title. Two axes decide how it looks — variant for how
loud it is, tone for which semantic colour set it draws from — and every
pairing of the two is a complete colour set, so there is no combination that
needs a special case.
Import
import { Badge } from "@forte-ui/react";Examples
Variants and tones
Four variants and seven tones, and all twenty-eight cells work without anything
being added per pair. soft is the default: a badge annotates the content next
to it, and a column of solid chips reads as the content rather than as labels
on it.
softprimarysecondaryneutraldangersuccesswarninginfosolidprimarysecondaryneutraldangersuccesswarninginfooutlineprimarysecondaryneutraldangersuccesswarninginfoghostprimarysecondaryneutraldangersuccesswarninginfoThree of the tones — success, warning, info — exist for this component
more than for any other. A badge is where an app says Active, Pending,
Failed, and an app that has to reach for a hex value to say it loses theming,
dark mode and forced-colors support in a single line.
Sizes
The three sizes differ mostly in how much air surrounds the label. The type
scale bottoms out at --forte-font-size-1, so sm and md share it and lg
steps up; what separates sm from md is that sm drops its block padding
entirely.
Shipping v4.2.0 to the canary channel, with 3 fixes behind a flag.
A badge's height comes from its own text — one tight line plus padding — rather
than from --forte-control-h-*. That is what lets it sit inside a sentence
without stretching the line, and it is also why data-forte-density does not
resize it: density is about how tightly controls pack, and a badge is not
one.
Status
dot puts a small filled dot before the label — the status-chip convention.
The dot is decorative and aria-hidden; the word beside it is what carries the
status.
Counts
Pass count and the badge stops behaving like a word.
count does two things that are easy to mistake for one. It prints the
number, capped by max — which defaults to 99, so count={480} reads
99+ and a runaway tally cannot stretch the chip across the row. And it
switches the badge to count geometry:
<Badge shape="pill">1</Badge> | <Badge shape="pill" count={1} /> | |
|---|---|---|
| measured | 25.7 × 24.4 — a slightly squashed egg | 24.4 × 24.4 — a circle |
9 → 10 | the width jumps and the chip twitches | grows once, digits stay aligned |
Three declarations get you there: tabular figures, so every digit is the same width; tighter inline padding; and a minimum inline size equal to the badge's own height. The last two work together — with a word's padding on both sides a single digit already measures wider than the badge is tall, so without the tightening the minimum would never bind and the circle would come out an egg.
Two escape hatches, because max is a blunt instrument:
max={Infinity}prints the number whatever it reaches.childrenbeat the printed number, so<Badge count={1240}>1.2k</Badge>keeps your own formatting and the geometry. That is whereIntl.NumberFormatgoes — the component deliberately renders withStringrather thantoLocaleString, because a locale-formatted number is one of the few values that differs between the server and the browser, and a hydration mismatch over a thousands separator is a page-level error.
On a button
A count on a button — unread messages, pending invites — comes in two shapes, and the accessible one is the one most people skip.
Sending adds one, the bell clears them. Hold the button down past 99 and the badge stops at 99+ instead of stretching — and the bell’s accessible name keeps the real number.
Inline puts the badge among the button's children, so the count lands in the button's own accessible name and is read as part of it. Nothing has to be duplicated, nothing goes stale. Prefer it.
Corner wraps the button in a relative inline-flex span and positions the
badge over it. It buys a tighter layout and costs three things you have to get
right:
- The count is no longer in the button's name, so put it there yourself — an
aria-labelof "Notifications, 12 unread" — and mark the badgearia-hidden. A visible "12" that reaches a screen reader as a bare number beside an unnamed button is worse than no badge at all. pointer-events-noneon the badge, or it swallows the click meant for the button under it.-end-1, never-right-1. The badge has to cross to the other corner in RTL, and the demo frame has a toggle to prove it.
Icons
An svg child is sized at 1em, so it tracks the label through every size
without a class or a prop. Mark it aria-hidden: the word beside it already
says what it means.
Links and buttons
render swaps the element, which is how a badge becomes a tag link or a
filter button.
There is no interactive prop to go with it. The styles read the rendered
element — an <a href>, a <button>, or anything carrying role="button" or
role="link" — and only those get the pointer cursor, the hover wash and a
24×24 hit target. A prop would be a second source of truth that a consumer
could forget to set, and forgetting it is exactly the SC 2.5.8 failure the hit
target exists to prevent.
In a table
The shape most badges actually ship in — one column of status, one badge per row.
Accessibility
A badge is a label, not a control, and it renders a plain <span> with no
role. That is deliberate: the text inside it is already read in document
order, and a role="status" on every chip would turn a table of four invoices
into four live regions.
Three things are worth doing at the call site:
- Say it in words. Colour and a dot are reinforcement. The label is the message — see the callout above.
- Give a bare count a name.
<Badge count={12} />next to an inbox icon reads as "twelve" and nothing else. Put the noun somewhere: visible text, anaria-labelon the surrounding button or link, or a.forte-visually-hiddenspan inside the badge. - Announce a count that changes. A badge that updates while the page is open changes silently; if the change matters, wrap it in your own live region rather than making every badge one.
Under forced colours the four variants converge on a single outlined chip, and
that is correct rather than a compromise: author backgrounds are replaced by
system colours, so the fill can no longer carry the difference. Each variant
reserves its 1px border whether or not it paints one, and transparent becomes
a real system-coloured edge in that mode — the same trick .forte-hc-surface
plays, applied to every variant at once. The dot is repainted as CanvasText,
since a background would otherwise go to Canvas and disappear.
Theming
Every visual decision is a --forte-badge-* custom property declared on the
badge's own root, so a re-skin is a style object or one CSS rule rather than a
new variant.
Two of the knobs are worth reading twice:
--forte-badge-border-widthis reserved by every variant, not onlyoutline. Changing it moves all four together, which is the point — a variant swap must not resize the badge, and the reserved edge is what keeps the chip visible under forced colours.--forte-badge-min-inline-sizeis whatcountre-points, and its value restates the badge's own height in acalc()because CSS has nothing to measure that height with. Override the padding or the line height on a count badge and you own the minimum too, or the circle stops being round.
| Property | Controls | Default |
|---|---|---|
--forte-badge-radius | Corner radius. shape="pill" re-points it. | var(--forte-radius-control) |
--forte-badge-font-size | Label size; reassigned by size. | var(--forte-font-size-1) |
--forte-badge-font-weight | Label weight. Badges are small; medium is what keeps them legible against body text without shouting. | var(--forte-font-weight-medium) |
--forte-badge-gap | Space between the dot, an icon and the label. | var(--forte-space-1) |
--forte-badge-padding-y | Block padding; reassigned by size. Together with one tight line this is what sets the badge's height. | var(--forte-space-1) |
--forte-badge-padding-x | Inline padding; reassigned by size. | var(--forte-space-2) |
--forte-badge-border-width | Border thickness. Reserved by every variant; only outline colours it. | 1px |
--forte-badge-icon-size | Size of an svg child. 1em, so it tracks --forte-badge-font-size and needs no rule per size — and stays in proportion if a consumer re-points the font size. | 1em |
--forte-badge-dot-size | Diameter of the leading dot. | 0.5em |
--forte-badge-dot-color | Colour of that dot. currentColor by default, so it matches the label through every variant and tone without a slot of its own. | currentColor |
--forte-badge-min-inline-size | Smallest inline size. count re-points it at the badge's own height, which is what rounds a one-digit tally into a circle. | auto |
--forte-badge-duration | How long a hover or theme change takes to cross-fade. | var(--forte-duration-fast) |
--forte-badge-tone-solid | Tone's solid fill | var(--forte-color-primary) |
--forte-badge-tone-solid-hover | Tone's solid fill under the pointer, for a badge that is a link or a button. The three tones with no authored hover step derive one further down. | var(--forte-color-primary-hover) |
--forte-badge-tone-on-solid | Text colour on the solid fill | var(--forte-color-on-primary) |
--forte-badge-tone-soft | Tone's tinted fill, and the hover wash for outline and ghost | var(--forte-color-primary-soft) |
--forte-badge-tone-soft-hover | Tinted fill under the pointer. The four tones with no authored soft-hover step derive one further down. | var(--forte-color-primary-soft-hover) |
--forte-badge-tone-text | Label colour for soft, outline and ghost | var(--forte-color-primary-text) |
--forte-badge-tone-border | Border colour for outline | var(--forte-color-primary-border) |
--forte-badge-bg | Resting background; filled in by variant | transparent |
--forte-badge-bg-hover | Background under the pointer, for an interactive badge only | transparent |
--forte-badge-fg | Label and icon colour | var(--forte-color-foreground) |
--forte-badge-border-color | Border colour | transparent |
The two hover fills are the only derived values in the file. The four status
ramps carry steps 2, 3, 7, 9 and 11 and no step 4 or 10, so there is no
authored hover shade to name for success, warning and info — those tones
derive one with color-mix() instead: solid mixes 8% toward black, which is
how the accent ramp defines its own step 10 and the only direction that cannot
cost the label contrast, and soft mixes 8% toward the tone's own text colour.
Tones that do ship a hover token re-point the slot to it, so nothing is
approximated where an authored value exists.
State is on data-variant, data-tone, data-size, data-shape and
data-count — which carries the number itself, so a rule can key off the
value it came from — so a consumer can reach any of them from plain CSS or a
Tailwind arbitrary variant (data-[tone=success]:...) without wrapping the
component.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | Additional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`. | |
count | number | The number this badge is showing — an unread tally, a pending count. Passing it does two separate things. It renders the number (capped by `max`), and it switches the badge to count geometry: figures stop changing width as the tally ticks over, the inline padding tightens, and the badge takes a minimum inline size equal to its own height. That last pair is what makes a single digit a circle rather than a slot — with a word's padding, `1` already measures wider than the badge is tall. `children` still win over the rendered number, and that is the hook for anything `max` cannot express: `<Badge count={1240}>1.2k</Badge>` keeps your formatting and the geometry both. | |
dot | boolean | false | Draw a small filled dot before the label — the status-chip convention. It is decorative and `aria-hidden`: the status is in the words. A dot that carried meaning on its own would be colour as the sole cue, which is the failure (SC 1.4.1) this component is otherwise careful to avoid. |
max | number | 99 | The largest number `count` will print. Above it the badge shows `${max}+` instead, so a tally cannot widen the chip without limit. 99 matches what a notification badge conventionally does. Pass `Infinity` to print the number whatever it reaches. |
render | RenderProp<Record<string, unknown>> | Replaces the rendered `<span>` with another element or component. `render={<a href="/releases" />}` or `render={<Link href="/inbox" />}` is how a badge becomes navigable. There is no `interactive` prop to go with it: the styles read the element itself, so an `<a href>` or a `<button>` picks up the pointer cursor, the hover wash and a 24×24 hit target on its own, and a plain `<span>` never pretends to be clickable. | |
shape | BadgeShape | rounded | Corner treatment. `rounded` follows `--forte-radius-control`, so it moves with a `data-forte-radius` preset like everything else; `pill` is fully round, which is the conventional shape for a count. |
size | BadgeSize | md | Size of the badge. The three differ mostly in how much air surrounds the label: the type scale bottoms out at `--forte-font-size-1`, so `sm` and `md` share it and `lg` steps up. Unlike a control, a badge is not resized by `data-forte-density` — its height comes from the text it sits beside, which is the thing it should track. |
tone | BadgeTone | primary | Which semantic colour set the badge draws from. Combines freely with `variant` — every pairing is a complete colour set, so `tone="success" variant="outline"` needs nothing added to work. The four status tones are the reason this component has seven rather than the four `Button` has: a badge is where an app says "Active", "Pending", "Failed", and an app that has to reach for a hex value to say it has lost theming, dark mode and forced-colors support in one line. |
variant | BadgeVariant | soft | How much visual weight the badge carries. `soft` is the default because a badge annotates something else on the page and should not outrank it — a row of solid chips down a table column reads as the content rather than as labels on it. Reach for `solid` when the badge IS the message, which is usually a count or a single alarm. |