docs / core / notification-badge

NotificationBadge

a small count or dot pinned to the corner of whatever it wraps: an icon, a button, an avatar. it vanishes at zero, so silence stays silent.

intensity · recedesrsc · server

provenance

shaped from sisu-plus

layer

core / primitives

intensity

recedes · safe anywhere, including dense surfaces

composition

wraps an icon-only Button, an avatar, a nav itemdot mode for presence, count mode for unreadnot a status Badge. it counts, it does not labelnever on a bare icon with no accessible name of its own

a11y

the dot is aria-hidden · the wrapper never steals the child's role or namejest-axe
live demo · try it out
3
customize
countthe number shown
3
maxcap before N+
9
dotbare presence dot, no number
toneindicator colour
showZerostay visible at 0
usagecopy
import { Button } from "usva/primitives/button";
import { NotificationBadge } from "usva/primitives/notification-badge";

<NotificationBadge count={3}>
  <Button variant="outline" iconOnly aria-label="Notifications">
    <BellIcon />
  </Button>
</NotificationBadge>

props

proptypedefaultnotes
countnumber0the count to show. hidden at 0 unless showZero.
maxnumber9cap before showing "N+".
dotbooleanfalsea bare presence dot instead of a number.
tone"accent" | "accent-alt" | "danger" | "warning""danger"indicator color.
showZerobooleanfalsekeep the badge visible at 0.

get it

npx shadcn add https://usva.build/r/notification-badge.jsoncopy
source · components/ui/notification-badge.tsxexactly what this command copiescopy
import * as React from "react";
import { cn } from "@/lib/utils";

export type NotificationTone = "accent" | "accent-alt" | "danger" | "warning";

export interface NotificationBadgeProps
  extends React.HTMLAttributes<HTMLSpanElement> {
  /** The count to show. Hidden at 0 unless `showZero`. */
  count?: number;
  /** Cap before showing "N+". Defaults to 9. */
  max?: number;
  tone?: NotificationTone;
  /** Show a bare dot instead of a number. */
  dot?: boolean;
  showZero?: boolean;
}

const toneClass: Record<NotificationTone, string> = {
  accent: "bg-accent text-on-accent",
  "accent-alt": "bg-accent-alt text-on-accent",
  danger: "bg-danger text-on-accent",
  warning: "bg-warning text-on-accent",
};

export const NotificationBadge = React.forwardRef<
  HTMLSpanElement,
  NotificationBadgeProps
>(
  (
    {
      className,
      count = 0,
      max = 9,
      tone = "danger",
      dot = false,
      showZero = false,
      children,
      ...props
    },
    ref,
  ) => {
    const visible = dot || count > 0 || showZero;
    return (
      <span
        ref={ref}
        className={cn("relative inline-flex", className)}
        {...props}
      >
        {children}
        {visible &&
          (dot ? (
            <span
              aria-hidden="true"
              className={cn(
                "absolute -top-0.5 -right-0.5 h-2.5 w-2.5 rounded-full ring-2 ring-surface",
                toneClass[tone],
              )}
            />
          ) : (
            <span
              className={cn(
                "absolute -top-1.5 -right-1.5 inline-flex h-[1.15rem] min-w-[1.15rem] items-center justify-center rounded-full px-1 font-mono text-[0.625rem] font-semibold leading-none tabular-nums ring-2 ring-surface",
                toneClass[tone],
              )}
            >
              {count > max ? `${max}+` : count}
            </span>
          ))}
      </span>
    );
  },
);
NotificationBadge.displayName = "NotificationBadge";