---
title: "Badge"
description: "A small neutral label attached to something else, such as a count on a tab or a word on a list row. It takes neither colour axis and never stands in for status."
url: "https://opsinjs.pensievelabs.org/components/badge"
source: "https://opsinjs.pensievelabs.org/components/badge.md"
section: "Components"
status: "shipped"
kind: "component"
category: "data-display"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["counter", "label chip"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="badge"
  status="shipped"
  questions="[
  &#x22;Nothing stops a caller passing a status or category colour through className, which is the exact axis mixing the roster cited when it declined a generic badge.&#x22;,
  &#x22;Nobody has heard a badge in a screen reader, so whether a labelled count reads usefully in the flow of its host is reasoned rather than listened to.&#x22;,
  &#x22;No contrast pair has been measured, so whether the soft chip and the outline chip read at the text and non-text floors is argued from the roles alone.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="badge" />

## Installation [#installation]

<ComponentInstall name="badge" unbuilt="false" importPath="@/components/ui/badge" />

## Usage [#usage]

```tsx
import { Badge } from "@/components/ui/badge"
```

```tsx
<span className="inline-flex items-center gap-2">
  Messages
  <Badge srLabel="3 unread">3</Badge>
</span>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A small neutral count attached to a label, such as an unread count on a tab, where the number carries no clinical meaning.&#x22;,
  &#x22;A short neutral word on a list row or a heading, such as a category name the product owns, that is not a status.&#x22;,
  &#x22;A compact chip of neutral chrome that reads more calmly than a full sentence, as long as it carries content rather than urgency.&#x22;,
]"
  avoid="[
  { case: &#x22;The label says what a reading means or how urgent it is. A badge would carry that as colour alone.&#x22;, instead: &#x22;status-pill&#x22; },
  { case: &#x22;You are showing a measurement and its unit rather than a count or a word.&#x22;, instead: &#x22;value&#x22; },
  { case: &#x22;The chip has to be tapped to do something. A badge is content, not a control, and takes no focus.&#x22;, instead: &#x22;button&#x22; },
]"
/>

## Examples [#examples]

### A count on a label [#a-count-on-a-label]

Each badge attaches a fictional count to the label beside it. `srLabel` names
that count, so a screen reader hears "5 waiting" rather than a lone "5".

<ComponentPreview name="badge-a-count-on-a-label" kind="example" align="start" />

### The two weights [#the-two-weights]

`variant` chooses `soft` or `outline`, and both are neutral. Read in greyscale
they are two quiet chips, and a status pill beside them would still be the only
thing carrying a level.

<ComponentPreview name="badge-the-two-weights" kind="example" align="start" />

## Content guidelines [#content-guidelines]

Keep a badge to a few characters: a number, or a short neutral word. Give a bare
number a noun through `srLabel`, so a screen reader hears what it counts. Never
write a clinical status word into a badge. "Steady" and "Needs attention" belong
in a [StatusPill](./status-pill.mdx), beside the colour and the glyph that make
the level readable.

<DoDont>
  <DoDont.Do>
    **"3" with `srLabel="3 unread"`** names the count for a screen reader and
    stays quiet chrome.
  </DoDont.Do>

  <DoDont.Dont>
    **A red badge reading "High" beside a reading** tells a clinical level by
    colour alone. That is a StatusPill.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Audited against WCAG 2.2 AA**, in a source pass and a rendered pass. The
audit is author-run, not an independent review, and clinical review is still
pending.

The source pass found one real defect and fixed it: `srLabel` set `aria-label`
on a bare `span`, whose implicit role is generic and does not reliably carry
an author-supplied name to assistive technology, so a caller's count risked
being silently dropped in favour of the visible digit. The badge now pairs
`aria-label` with `role="img"` whenever `srLabel` is a non-empty string, the
same pairing already used on [Avatar](./avatar.mdx). Nothing else about the
markup, the props or the rendered output changed.

**Checked by `pnpm run check:a11y` on every commit.** The badge draws only the
neutral chrome roles, carries no `data-status`, and sets its text at the
`caption1` semantic step rather than a `px` size.

**Argued from the source, not measured.**

* `srLabel=""` sets `aria-hidden`, so a count already spoken beside the badge is
  not read twice.
* `srLabel` with text sets `aria-label` and `role="img"` together, so a bare
  count is named reliably rather than risking a lone number.
* Nothing takes focus, answers a key or animates, so there is no keyboard
  contract and nothing for `prefers-reduced-motion` to reduce.
* At 200% text and under a wider text-spacing override the chip grows with the
  text rather than clipping it, because nothing fixes its height or hides its
  overflow.

**Residual, disclosed rather than fixed.** No contrast pair in either weight
has been measured against real token values, so the numbers below are
whatever the generator has produced so far. Nothing in the component checks
that a caller's `srLabel` textually contains the visible content, so a caller
who ignores the guidance in Content guidelines could still write a disjoint
label; that stays a documented usage contract rather than enforced behaviour.
Whether a screen reader speaks a labelled badge usefully in the flow of its
host still has not been listened to, only reasoned about from the roles.

<KeyboardTable
  name="badge"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Skips the badge&#x22;,
    notes: &#x22;A badge is content, not a control. Nothing here is focusable.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Skips the badge&#x22;,
    notes: &#x22;Same in reverse. A badge never appears in the focus order.&#x22;,
  },
  {
    keys: &#x22;Arrow keys&#x22;,
    action: &#x22;Nothing&#x22;,
    notes: &#x22;There is nothing to select, move or expand.&#x22;,
  },
]"
/>

<ContrastReport component="badge" />

## API reference [#api-reference]

<PropsTable name="BadgeProps" />

`variant` defaults to `soft` and chooses a neutral weight only. There is no
colour prop, because a badge is on neither axis, and colour passed through
`className` is the caller's to keep off both. `srLabel` names a bare count, and
an empty string hides a decorative badge from the accessibility tree.

## Related [#related]

* [StatusPill](./status-pill.mdx) carries a clinical level as colour, icon and
  word together, which a neutral badge cannot.
* [Value](./value.mdx) formats a measurement and its unit. A badge counts or
  names and formats nothing.
* [Callout](./callout.mdx) sets information apart in a block. A badge is a chip
  attached inline to something else.
