Skip to content

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.

badge/basic.tsx
BetaActiveDegradedFailedDraft12

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.

badge/variants.tsx
softprimarysecondaryneutraldangersuccesswarninginfo
solidprimarysecondaryneutraldangersuccesswarninginfo
outlineprimarysecondaryneutraldangersuccesswarninginfo
ghostprimarysecondaryneutraldangersuccesswarninginfo

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

badge/sizes.tsx
smmdlg

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.

badge/status.tsx
OperationalDegradedOutageMaintenanceUnknown

Counts

Pass count and the badge stops behaving like a word.

badge/counts.tsx
11299+4801.2k
9Tabular figures, so 9 → 10 grows once and never jitters again.

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} />
measured25.7 × 24.4 — a slightly squashed egg24.4 × 24.4 — a circle
910the width jumps and the chip twitchesgrows 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.
  • children beat the printed number, so <Badge count={1240}>1.2k</Badge> keeps your own formatting and the geometry. That is where Intl.NumberFormat goes — the component deliberately renders with String rather than toLocaleString, 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.

badge/on-a-button.tsx

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-label of "Notifications, 12 unread" — and mark the badge aria-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-none on 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.

badge/icons.tsx
MergedIn reviewConflictsPrivateNew

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.

badge/links.tsx

In a table

The shape most badges actually ship in — one column of status, one badge per row.

badge/table.tsx
InvoicePlanStatus
INV-2041ScalePaid
INV-2040TeamDue today
INV-2039TeamFailed
INV-2038StarterDraft

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, an aria-label on the surrounding button or link, or a .forte-visually-hidden span 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.

badge/custom.tsx
v0.4.0eu-west-1Preview build

Two of the knobs are worth reading twice:

  • --forte-badge-border-width is reserved by every variant, not only outline. 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-size is what count re-points, and its value restates the badge's own height in a calc() 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.
Theming tokens for Badge
PropertyControlsDefault
--forte-badge-radiusCorner radius. shape="pill" re-points it.var(--forte-radius-control)
--forte-badge-font-sizeLabel size; reassigned by size.var(--forte-font-size-1)
--forte-badge-font-weightLabel weight. Badges are small; medium is what keeps them legible against body text without shouting.var(--forte-font-weight-medium)
--forte-badge-gapSpace between the dot, an icon and the label.var(--forte-space-1)
--forte-badge-padding-yBlock padding; reassigned by size. Together with one tight line this is what sets the badge's height.var(--forte-space-1)
--forte-badge-padding-xInline padding; reassigned by size.var(--forte-space-2)
--forte-badge-border-widthBorder thickness. Reserved by every variant; only outline colours it.1px
--forte-badge-icon-sizeSize 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-sizeDiameter of the leading dot.0.5em
--forte-badge-dot-colorColour 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-sizeSmallest 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-durationHow long a hover or theme change takes to cross-fade.var(--forte-duration-fast)
--forte-badge-tone-solidTone's solid fillvar(--forte-color-primary)
--forte-badge-tone-solid-hoverTone'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-solidText colour on the solid fillvar(--forte-color-on-primary)
--forte-badge-tone-softTone's tinted fill, and the hover wash for outline and ghostvar(--forte-color-primary-soft)
--forte-badge-tone-soft-hoverTinted 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-textLabel colour for soft, outline and ghostvar(--forte-color-primary-text)
--forte-badge-tone-borderBorder colour for outlinevar(--forte-color-primary-border)
--forte-badge-bgResting background; filled in by varianttransparent
--forte-badge-bg-hoverBackground under the pointer, for an interactive badge onlytransparent
--forte-badge-fgLabel and icon colourvar(--forte-color-foreground)
--forte-badge-border-colorBorder colourtransparent

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

Props for Badge
PropTypeDefaultDescription
classNamestringAdditional class name(s). Applied after the internal styles so consumer utilities (e.g. Tailwind) win without needing `!important`.
countnumberThe 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.
dotbooleanfalseDraw 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.
maxnumber99The 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.
renderRenderProp<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.
shapeBadgeShaperoundedCorner 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.
sizeBadgeSizemdSize 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.
toneBadgeToneprimaryWhich 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.
variantBadgeVariantsoftHow 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.