DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

Badge

A small indicator for counts and statuses. Four weights via variant, five meanings via intent, four sizes — and a shape that follows what you give it: a dot, a label, or a label with a leading dot.

Playground

Installation

pnpm add @tessinaui/ui

Usage

import { Badge } from "@tessinaui/ui";

Badge speaks the same prop vocabulary as the rest of the library: variant is visual weight, intent is meaning, plus size, rounded and tone. There is no shape prop — what the badge renders follows from what you give it.

{/* No value → a dot */}
<Badge intent="error" />

{/* A value → a label. Numbers are capped by `max` (default 99) */}
<Badge value={120} />           {/* renders "99+" */}

{/* A value + showDot → a leading dot beside the text */}
<Badge variant="secondary" intent="success" size="lg" value="Active" showDot />

{/* Weight and meaning are independent */}
<Badge variant="outline" intent="warning" value="Pending" />
<Badge variant="ghost" intent="info" value="Synced" />

{/* Link badge — render swaps the host element */}
<Badge variant="ghost" value="Docs" render={<a href="/docs" />} />

Examples

Default

A neutral solid badge.

Status

Pass showDot alongside a value for a leading dot. The text carries the meaning — the dot is aria-hidden and never the only channel.

Variants

primary is the solid fill, secondary the tinted plate, outline a ring, ghost ink only — the same four weights the button family declares.

Intents

Five meanings; none is the neutral default.

Dot

Pass no value and the badge is a bare indicator.

Sizes

sm, md, and lg.

Link badges

render swaps the span for any element. On an anchor the hover fill and focus ring activate; on the default span they are inert.

Icons

Spinner

RTL

Anchored

On color

API Reference

Props

PropTypeDefaultDescription
variant"primary" | "secondary" | "outline" | "ghost""primary"Visual weight. primary fills, secondary tints, outline rings, ghost is ink only
intent"none" | "error" | "warning" | "success" | "info""none"Semantic meaning. none renders neutral
size"xs" | "sm" | "md" | "lg""sm"Diameter / height: 8 / 16 / 20 / 24 px. xs is dot-only
rounded"full" | "xl" | "lg" | "md" | "sm" | "none""full"Corner rounding — a dot is a circle at every value
valuestring | number—The content. Omit it for a dot. Numbers get count semantics; strings render verbatim
showDotbooleanfalseRender a leading dot beside the text
maxnumber99Cap for a numeric value — larger numbers render "{max}+"
showZerobooleanfalseRender a numeric 0 instead of hiding the badge
labelstring—Visually hidden text for standalone badges ("3 unread notifications")
tone"default" | "on-color"inheritedColored-surface mode — resolved from the nearest <Surface>; explicit value wins
renderuseRender.RenderProp<span />Host element — render={<a href … />} makes a link badge
leadingIconReactNode—Icon before the content (after the dot). svg auto-sized per badge size
trailingIconReactNode—Icon after the content
dir"ltr" | "rtl"—Native dir attribute — badge layout is logical, so RTL mirrors for free
maxWidthnumber | string—Bounds the visible text with an ellipsis — keep the full text in label
classNamestring—Extra classes on the host

All other native <span> attributes are forwarded. Stable hooks for tooling: data-slot="badge", data-shape, data-variant, data-intent, data-tone, and data-slot="badge-dot" on the leading dot.

Deprecated props

appearance, color and the shape values of variant still work for one minor and map onto the axes above. resolveBadgeAxes is exported if you need to ask what a combination resolves to.

BeforeAfter
appearance="solid"variant="primary" (the default)
appearance="soft"variant="secondary"
appearance="outline" / "ghost"variant="outline" / "ghost"
color="error" (or warning/success/info)intent="error"
color="primary" / "secondary"variant="primary" / "secondary" — they were weight, not meaning
variant="dot"pass no value
variant="label"pass value
variant="status"pass value + showDot

Two behaviour changes came with the rename. A bare <Badge /> is neutral, not red — intent defaults to none, so pass intent="error" for the notification dot. And the brand-tinted quiet tiers (soft/outline/ghost combined with color="primary") had no equivalent in the shared vocabulary and now resolve to the neutral tier; the solid brand badge is unchanged.

Shape

There is no shape prop. What the badge renders follows from its content, so a badge cannot be told to be a label and then given nothing to say:

You passYou get
no valuea dot
valuea label
value + showDota label with a leading dot
size="xs"a dot, whatever else you passed — 8px is too small for a glyph

Every dot paints the solid tier regardless of variant: a tinted or fill-less 8px mark is imperceivable.

Sizes

SizeDiameterLabel textIconUse case
xs8 px— (dot only)—Minimal presence indicator
sm16 px10 px10 pxIcon button overlays, nav items
md20 px12 px12 pxStandard notification counts
lg24 px12 px12 pxStandalone status badges

Counts

Numeric values are capped and zero-managed; strings never are.

<Badge value={120} />            {/* "99+" */}
<Badge value={1200} max={999} /> {/* "999+" */}
<Badge value={0} />              {/* renders nothing */}
<Badge value={0} showZero />     {/* "0" */}
<Badge value="+99" />            {/* "+99", verbatim */}

Long values are bounded with maxWidth — the visible text truncates with an ellipsis. Truncated text in a non-focusable element is unrecoverable, so keep labels to one or two words and put the full text in label whenever you truncate.

Anchored

BadgeAnchor pins a badge to a corner of the element it counts, centered on the anchor point. Positions are logical (top-end mirrors under RTL); overlap="circular" insets ~14% so the badge rides a round anchor's edge; halo separates it from busy artwork with a ring in the backdrop's own color. The pinned badge scales in on mount and respects reduced motion.

<BadgeAnchor overlap="circular" halo badge={<Badge intent="error" />}>
  <Avatar src={src} name="Jane Doe" />
</BadgeAnchor>
PropTypeDefaultDescription
badgeReactNode—The badge to pin. A zero-hidden badge renders nothing
position"top-end" | "top-start" | "bottom-end" | "bottom-start""top-end"Logical corner — mirrors under RTL
overlap"rectangular" | "circular""rectangular"circular insets ~14% for round anchors
halobooleanfalseBackdrop-colored separation ring

On color

On a colored surface the badge inherits tone="on-color" from the nearest Surface — no prop needed. A semantic solid keeps its own fill and gains a 12%-of-ink hairline so it cannot blend into a same-hue plate; a neutral solid inverts to the surface-ink chip; the quiet tiers take the matching ink tiers. This is the button family's split, from the same shared recipes. Explicit tone wins in both directions.

Interactive badges

By default a badge is a static <span> — no role, no focus, nothing to operate. render swaps the host; on an interactive host the built-in hover fill and focus ring go live. The boundary still holds: a link badge navigates. Anything that acts — filter, toggle, dismiss, input token — is a Chip.

Migrating from other libraries

ElsewhereTessina equivalent
defaultvariant="primary"
secondaryvariant="secondary"
destructiveintent="error"
outlinevariant="outline"
ghostvariant="ghost"
linkvariant="ghost" + render={<a />}
asChildrender
<Icon data-icon="inline-start" />leadingIcon={<Icon />}
<Icon data-icon="inline-end" />trailingIcon={<Icon />}

Do / Don't

  • Do put the count into the owning control's accessible name when the badge is attached to one — "Notifications, 3 unread".
  • Do give a standalone badge a label — the visible "3" means nothing without context.
  • Do use render={<a />} for a badge that navigates — it gets the hover fill and focus ring for free.
  • Don't give a badge actions — filter, toggle, dismiss, input token is a Chip.
  • Don't use a textless badge for presence (online/away/busy) — that's Status, which shape-codes what color can't carry.
  • Don't rely on the leading dot's color — the text is the signal; the dot is decoration.

Accessibility

By default Badge is a non-interactive <span> with no ARIA pattern — the WAI-ARIA APG defines none for badges. It renders no role, takes no focus, and has no keyboard interactions. A badge rendered as a link via render={<a />} is a native anchor: Tab reaches it, Enter activates it, and the hard-gated focus ring shows — link semantics come from the host element, not from the badge.

Screen readers. A roleless span ignores aria-label — ARIA naming does not apply to generic elements. Two supported paths:

{/* Anchored to a control: the control's name carries the count */}
<IconButton icon={<BellIcon />} aria-label="Notifications, 3 unread" />

{/* Standalone: label renders visually hidden real text */}
<Badge value={3} label="3 unread notifications" />

With showDot the visible text is the accessible content and the dot is aria-hidden. The badge never announces its own changes — a live region belongs to the surrounding context, not to an indicator.

Contrast. Every text pair the badge renders is hard-gated at WCAG AA 4.5:1 in both themes by pnpm audit:contrast (measured 5.87–15.72:1; most quiet-tier pairs also clear the AAA 7:1, which this system does not claim — CONVENTIONS §3). Hue outline rings and solid dot fills are gated at 3:1; the warning dot fill is reported INFO with a hairline ring as the affordance, since amber cannot reach 3:1 on white at any palette value.

Touch. A static badge is non-interactive, so the 44px floor does not apply (the same exemption as a static Chip tag). A link badge at 16–24px rides inside its row's touch target.

AT matrix. VoiceOver + Safari smoke on the docs page this cycle; NVDA at the next release pass (L4) per QA_FLOW §5.6.

Skeleton. BadgeSkeleton is silent by default (aria-hidden, no live region) and follows the same shape rule as the badge — no chars gives the dot box. Pass label="Loading unread count" to opt into one announcement.

AvatarBanner

On this page

PlaygroundInstallationUsageExamplesDefaultStatusVariantsIntentsDotSizesLink badgesIconsSpinnerRTLAnchoredOn colorAPI ReferencePropsDeprecated propsShapeSizesCountsAnchoredOn colorInteractive badgesMigrating from other librariesDo / Don'tAccessibility