<bpdm/ui />
Misc

Badge

A compact label for status, counts and tags — tones, appearances, status dots, removable chips, and corner notifications, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Badge is a compact label for status, categories, counts, and tags. It comes in nine tones across four appearances, with an optional status dot (and a pulse for live states) and a removable mode. NotificationBadge overlays a count or dot on the corner of an icon or button. In Angular these are <bpdm-badge> and <bpdm-notification-badge>.

Usage

Active
badge-usage.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeUsage() {
  return <Badge variant="success">Active</Badge>;
}
badge-usage.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-usage',
  imports: [BpdmBadge],
  templateUrl: './badge-usage.html',
})
export class BadgeUsage {}
badge-usage.html
<bpdm-badge variant="success">Active</bpdm-badge>

Variants

The variant prop sets the tone: neutral, secondary, primary, success, warning, info, help, destructive, and contrast.

neutralsecondaryprimarysuccesswarninginfohelpdestructivecontrast
badge-variants.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeVariants() {
  return (
    <div className="flex flex-wrap gap-2">
      <Badge variant="neutral">Neutral</Badge>
      <Badge variant="secondary">Secondary</Badge>
      <Badge variant="primary">Primary</Badge>
      <Badge variant="success">Success</Badge>
      <Badge variant="warning">Warning</Badge>
      <Badge variant="info">Info</Badge>
      <Badge variant="help">Help</Badge>
      <Badge variant="destructive">Error</Badge>
      <Badge variant="contrast">Contrast</Badge>
    </div>
  );
}
badge-variants.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-variants',
  imports: [BpdmBadge],
  templateUrl: './badge-variants.html',
})
export class BadgeVariants {}
badge-variants.html
<bpdm-badge variant="neutral">Neutral</bpdm-badge>
<bpdm-badge variant="secondary">Secondary</bpdm-badge>
<bpdm-badge variant="primary">Primary</bpdm-badge>
<bpdm-badge variant="success">Success</bpdm-badge>
<bpdm-badge variant="warning">Warning</bpdm-badge>
<bpdm-badge variant="info">Info</bpdm-badge>
<bpdm-badge variant="help">Help</bpdm-badge>
<bpdm-badge variant="destructive">Error</bpdm-badge>
<bpdm-badge variant="contrast">Contrast</bpdm-badge>

Appearances

The appearance prop styles each tone — soft (tinted, default), solid (filled), outline (bordered), and ghost (no chrome — pair with dot for table cells and dense lists).

SoftSolidOutlineGhost
badge-appearances.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeAppearances() {
  return (
    <div className="flex flex-wrap items-center gap-2.5">
      <Badge variant="primary" appearance="soft">Soft</Badge>
      <Badge variant="primary" appearance="solid">Solid</Badge>
      <Badge variant="primary" appearance="outline">Outline</Badge>
      <Badge variant="primary" appearance="ghost" dot>Ghost</Badge>
    </div>
  );
}
badge-appearances.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-appearances',
  imports: [BpdmBadge],
  templateUrl: './badge-appearances.html',
})
export class BadgeAppearances {}
badge-appearances.html
<bpdm-badge variant="primary" appearance="soft">Soft</bpdm-badge>
<bpdm-badge variant="primary" appearance="solid">Solid</bpdm-badge>
<bpdm-badge variant="primary" appearance="outline">Outline</bpdm-badge>
<bpdm-badge variant="primary" appearance="ghost" dot>Ghost</bpdm-badge>

Sizes

The size prop offers sm and md (default).

SmallMedium
badge-sizes.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeSizes() {
  return (
    <div className="flex items-center gap-2.5">
      <Badge variant="primary" size="sm">Small</Badge>
      <Badge variant="primary" size="md">Medium</Badge>
    </div>
  );
}
badge-sizes.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-sizes',
  imports: [BpdmBadge],
  templateUrl: './badge-sizes.html',
})
export class BadgeSizes {}
badge-sizes.html
<bpdm-badge variant="primary" size="sm">Small</bpdm-badge>
<bpdm-badge variant="primary" size="md">Medium</bpdm-badge>

Status dots

Add dot for a leading status dot, and pulse for live / in-progress states. The ghost appearance keeps just the dot and label — ideal inside tables and lists.

LiveDeployingDegradedOfflineDraft
HealthySyncingBlocked
badge-status.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeStatus() {
  return (
    <div className="flex flex-col gap-4">
      <div className="flex flex-wrap items-center gap-2.5">
        <Badge variant="success" dot pulse>Live</Badge>
        <Badge variant="info" dot pulse>Deploying</Badge>
        <Badge variant="warning" dot>Degraded</Badge>
        <Badge variant="destructive" dot>Offline</Badge>
        <Badge variant="neutral" dot>Draft</Badge>
      </div>
      {/* ghost — bare dot + label, no chrome */}
      <div className="flex flex-wrap items-center gap-4">
        <Badge appearance="ghost" variant="success" dot>Healthy</Badge>
        <Badge appearance="ghost" variant="info" dot pulse>Syncing</Badge>
        <Badge appearance="ghost" variant="destructive" dot>Blocked</Badge>
      </div>
    </div>
  );
}
badge-status.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-status',
  imports: [BpdmBadge],
  templateUrl: './badge-status.html',
})
export class BadgeStatus {}
badge-status.html
<div class="flex flex-wrap items-center gap-2.5">
  <bpdm-badge variant="success" dot pulse>Live</bpdm-badge>
  <bpdm-badge variant="info" dot pulse>Deploying</bpdm-badge>
  <bpdm-badge variant="warning" dot>Degraded</bpdm-badge>
  <bpdm-badge variant="destructive" dot>Offline</bpdm-badge>
  <bpdm-badge variant="neutral" dot>Draft</bpdm-badge>
</div>

<!-- ghost — bare dot + label, no chrome -->
<div class="flex flex-wrap items-center gap-4">
  <bpdm-badge appearance="ghost" variant="success" dot>Healthy</bpdm-badge>
  <bpdm-badge appearance="ghost" variant="info" dot pulse>Syncing</bpdm-badge>
  <bpdm-badge appearance="ghost" variant="destructive" dot>Blocked</bpdm-badge>
</div>

Removable

Set onRemove (React) / removable with (removed) (Angular) to show a remove button. The chip collapses and fades out, then fires the callback.

FrontendBackendDesignInfraDocs
badge-removable.tsx
import { useState } from 'react';
import { Badge } from '@bpdm/ui/badge';
import { Button } from '@bpdm/ui/button';

const INITIAL_TAGS = ['Frontend', 'Backend', 'Design', 'Infra', 'Docs'];

export function TagList() {
  const [tags, setTags] = useState(INITIAL_TAGS);
  return (
    <div className="flex flex-col items-start gap-4">
      <div className="flex min-h-7 flex-wrap gap-2">
        {tags.map((t) => (
          <Badge
            key={t}
            variant="neutral"
            onRemove={() => setTags((cur) => cur.filter((x) => x !== t))}
          >
            {t}
          </Badge>
        ))}
      </div>
      <Button size="sm" variant="secondary" appearance="ghost" onClick={() => setTags(INITIAL_TAGS)}>
        Reset
      </Button>
    </div>
  );
}
badge-removable.ts
import { Component, signal } from '@angular/core';
import { BpdmBadge, BpdmButton } from '@bpdm/ng';

const INITIAL_TAGS = ['Frontend', 'Backend', 'Design', 'Infra', 'Docs'];

@Component({
  selector: 'tag-list',
  imports: [BpdmBadge, BpdmButton],
  templateUrl: './badge-removable.html',
})
export class TagList {
  readonly tags = signal(INITIAL_TAGS);

  drop(tag: string) {
    this.tags.update((cur) => cur.filter((t) => t !== tag));
  }

  reset() {
    this.tags.set(INITIAL_TAGS);
  }
}
badge-removable.html
<div class="flex flex-col items-start gap-4">
  <div class="flex min-h-7 flex-wrap gap-2">
    @for (t of tags(); track t) {
      <bpdm-badge variant="neutral" removable (removed)="drop(t)">{{ t }}</bpdm-badge>
    }
  </div>
  <button bpdmButton size="sm" variant="secondary" appearance="ghost" (click)="reset()">Reset</button>
</div>

Notifications

NotificationBadge overlays a count (capped by max, e.g. 99+) or a dot on the corner of any child — a bell, an inbox, an avatar. By default a count of 0 hides it; pass showZero to keep it.

badge-notifications.tsx
import { useState } from 'react';
import { Bell, Mail } from 'lucide-react';
import { NotificationBadge } from '@bpdm/ui/badge';
import { Button } from '@bpdm/ui/button';

export function Notifications() {
  const [count, setCount] = useState(8);
  return (
    <div className="flex flex-col items-center gap-4">
      <div className="flex items-center gap-5">
        <Button size="icon" variant="secondary" appearance="ghost" aria-label="Notifications">
          <NotificationBadge count={count}>
            <Bell />
          </NotificationBadge>
        </Button>
        <Button size="icon" variant="secondary" appearance="ghost" aria-label="Inbox">
          <NotificationBadge count={128} max={99}>
            <Mail />
          </NotificationBadge>
        </Button>
        <Button size="icon" variant="secondary" appearance="ghost" aria-label="Status">
          <NotificationBadge dot variant="success">
            <Bell />
          </NotificationBadge>
        </Button>
      </div>
      <div className="flex items-center gap-2">
        <Button size="sm" variant="secondary" appearance="outline" onClick={() => setCount((c) => c + 1)}>
          Add
        </Button>
        <Button size="sm" variant="secondary" appearance="ghost" onClick={() => setCount(0)}>
          Clear
        </Button>
      </div>
    </div>
  );
}

Angular has no lucide component, so inline the icon SVGs (any icon works):

badge-notifications.ts
import { Component, signal } from '@angular/core';
import { BpdmNotificationBadge, BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'notifications',
  imports: [BpdmNotificationBadge, BpdmButton],
  templateUrl: './badge-notifications.html',
})
export class Notifications {
  readonly count = signal(8);
}
badge-notifications.html
<div class="flex items-center gap-5">
  <button bpdmButton size="icon" variant="secondary" appearance="ghost" aria-label="Notifications">
    <bpdm-notification-badge [count]="count()">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
        <path d="M10.268 21a2 2 0 0 0 3.464 0" />
        <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" />
      </svg>
    </bpdm-notification-badge>
  </button>
  <button bpdmButton size="icon" variant="secondary" appearance="ghost" aria-label="Inbox">
    <bpdm-notification-badge [count]="128" [max]="99">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
        <rect width="20" height="16" x="2" y="4" rx="2" />
        <path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
      </svg>
    </bpdm-notification-badge>
  </button>
</div>

<div class="flex items-center gap-2">
  <button bpdmButton size="sm" variant="secondary" appearance="outline" (click)="count.set(count() + 1)">Add</button>
  <button bpdmButton size="sm" variant="secondary" appearance="ghost" (click)="count.set(0)">Clear</button>
</div>

In React, asChild renders the badge as its child element — e.g. an anchor — so it stays a single, semantic, interactive element.

badge-link.tsx
import { Badge } from '@bpdm/ui/badge';

export function BadgeLink() {
  return (
    <Badge asChild variant="primary" appearance="soft">
      <a href="/docs">View docs →</a>
    </Badge>
  );
}

Angular has no asChild. Wrap the badge in the link, or set interactive and bind a (click):

badge-link.ts
import { Component } from '@angular/core';
import { BpdmBadge } from '@bpdm/ng';

@Component({
  selector: 'badge-link',
  imports: [BpdmBadge],
  templateUrl: './badge-link.html',
})
export class BadgeLink {}
badge-link.html
<a href="/docs">
  <bpdm-badge variant="primary" appearance="soft" interactive>View docs →</bpdm-badge>
</a>

Internationalization

The Badge has almost no built-in copy of its own — the badge's label is author-supplied, so you translate it in your own app exactly like the rest of the content you pass in. The one string the component owns is the removable badge's remove (X) button accessible name, set via a single messages object — a Partial<BadgeMessages> merged over the English default:

  • remove — the remove (X) button's accessible name (aria-label). Default "Remove".

  • React — pass messages to <Badge messages={{ remove: '…' }} onRemove={…}>.

  • Angular — bind [messages] on <bpdm-badge removable>.

NotificationBadge has no messages of its own — its indicator can count anything (unread messages, pending tasks, alerts), so instead of a built-in default you name each instance with the optional ariaLabel prop (React) / [ariaLabel] input (Angular), e.g. ariaLabel="5 unread messages". Setting it promotes the indicator to a role="status" region so the count is announced to assistive tech.

Badges mirror under dir="rtl" with no extra prop — the remove (X) button's spacing flips to the inline-start side, and the NotificationBadge moves to the inline-end top corner, so the indicator always hugs the reading-order end of its child.

The example below localises only the built-in remove label to German via messages, and gives a NotificationBadge a short accessible name — the badge's own text stays in your app's content:

badge-i18n.tsx
import { useState } from 'react';
import { Badge, NotificationBadge, type BadgeMessages } from '@bpdm/ui/badge';

// Only the built-in remove label is translated; your own copy stays as-is.
const messages: Partial<BadgeMessages> = {
  remove: 'Entfernen',
};

export function TagFilters() {
  const [tags, setTags] = useState(['Design', 'Frontend', 'Infra']);
  return (
    <div className="flex flex-wrap items-center gap-2">
      {tags.map((t) => (
        <Badge
          key={t}
          variant="neutral"
          messages={messages}
          onRemove={() => setTags((cur) => cur.filter((x) => x !== t))}
        >
          {t}
        </Badge>
      ))}
      <NotificationBadge count={5} ariaLabel="5 unread notifications">
        <span>Inbox</span>
      </NotificationBadge>
    </div>
  );
}
badge-i18n.ts
import { Component, signal } from '@angular/core';
import { BpdmBadge, BpdmNotificationBadge, type BadgeMessages } from '@bpdm/ng';

@Component({
  selector: 'tag-filters',
  imports: [BpdmBadge, BpdmNotificationBadge],
  templateUrl: './badge-i18n.html',
})
export class TagFilters {
  readonly tags = signal(['Design', 'Frontend', 'Infra']);
  // Only the built-in remove label is translated; your own copy stays as-is.
  readonly messages: Partial<BadgeMessages> = {
    remove: 'Entfernen',
  };

  drop(tag: string) {
    this.tags.update((cur) => cur.filter((t) => t !== tag));
  }
}
badge-i18n.html
<div class="flex flex-wrap items-center gap-2">
  @for (t of tags(); track t) {
    <bpdm-badge variant="neutral" [messages]="messages" removable (removed)="drop(t)">{{ t }}</bpdm-badge>
  }
  <bpdm-notification-badge [count]="5" ariaLabel="5 unread notifications">
    <span>Inbox</span>
  </bpdm-notification-badge>
</div>

API

Badge / <bpdm-badge>

PropTypeDefaultDescription
variantneutral | secondary | primary | success | warning | info | help | destructive | contrastneutralColour tone.
appearancesoft | solid | outline | ghostsoftFill style.
sizesm | mdmdBadge height and text size.
dotbooleanfalseLeading status dot, tinted to the variant.
pulsebooleanfalseAnimate the dot with a pulse ring (live status).
interactivebooleanfalseAngular — cursor + press feedback; pair with a (click). In React this is inferred from asChild or an onClick.
onRemove() => voidReact — show a remove button; collapses + fades, then fires. Angular: removable + (removed).
removablebooleanfalseAngular — show the remove button; emits removed after the collapse.
messagesPartial<BadgeMessages>EnglishOverride the translatable strings — currently just the remove button's accessible name (see below). Angular: [messages].
asChildbooleanfalseReact — render as the child element (e.g. an <a>).
classNamestringExtra classes on the root. Angular: class.

The label is children (React) / projected content (Angular). In React all native <span> attributes are forwarded onto the root. The remove affordance is the one place the two frameworks differ by name: React fires the onRemove callback, while Angular sets removable and listens to the (removed) output.

NotificationBadge / <bpdm-notification-badge>

PropTypeDefaultDescription
countnumberNumber to display in the corner.
maxnumber99Cap; values above show {max}+.
dotbooleanfalseShow a dot instead of a number.
showZerobooleanfalseKeep the badge when count is 0.
variantBadgeVariantdestructiveColour of the overlay.
ariaLabelstringAccessible name for the count/dot indicator; setting it promotes the indicator to role="status". A per-instance label (not part of messages). Angular: [ariaLabel].
classNamestringExtra classes on the wrapper.

Both are content-projection components — the overlaid child (icon, avatar, button) is children (React) / projected content (Angular).

App-wide copy — messages

Localise the one built-in string — the removable badge's remove-button accessible name — in a single place (see Internationalization). It's a Partial<BadgeMessages> merged over the English default.

  • React — the messages prop on <Badge>.
  • Angular — the [messages] input on <bpdm-badge>.
BadgeMessages keyDefaultDescription
removeRemoveAccessible name (aria-label) for the remove (X) button on a removable badge.

NotificationBadge's ariaLabel is a separate per-instance prop — it names one indicator (the count can mean anything), so it lives outside messages and has no app-wide default.

Accessibility

  • A badge is a plain text label — its meaning comes from the content you write ("Active", "3 errors"), read in order by assistive tech, so keep the label self-explanatory rather than relying on colour.
  • A leading status dot is decorative (aria-hidden) — status is never conveyed by colour alone; the visible label carries it (a success badge reads "Live", not "the green one").
  • The removable badge's remove control is a real, focusable <button> with a visible keyboard focus ring, an accessible name from messages.remove (default "Remove"), and a pointer cursor on hover.
  • NotificationBadge's indicator is decorative chrome by default (a visual cue on an already-labelled control) — give the underlying control its own accessible name (e.g. aria-label="Notifications"), and when the count itself must be announced, set ariaLabel on the indicator to name it and promote it to role="status".
  • With asChild / a link badge, the underlying <a>/<button> keeps its native role and keyboard behaviour, and its content becomes the accessible name — so it stays fully keyboard-operable.
  • Everything mirrors under dir="rtl" — the remove (X) button's spacing flips to the inline-start side and the NotificationBadge moves to the inline-end top corner.

On this page