# Notification Badge

> A counter or dot on an icon or avatar, cut out with CSS shape() so it reads on any background

A React component for projects built on shadcn/ui. The [interactive docs](https://alexandrejoly.me/components/notification-badge) show each example live.

```tsx
import { BellIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  NotificationBadge,
  NotificationBadgeContent,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";
import { useState } from "react";

export const NotificationBadgeDemo = () => {
  const [count, setCount] = useState(3);

  return (
    <div className="flex flex-col items-center gap-6">
      <NotificationBadge value={count}>
        <NotificationBadgeContent>
          <Button
            aria-label={
              count > 0 ? `Notifications, ${count} unread` : "Notifications"
            }
            size="icon-lg"
            variant="outline"
          >
            <BellIcon />
          </Button>
        </NotificationBadgeContent>
        <NotificationBadgeIndicator />
      </NotificationBadge>
      <div className="flex gap-2">
        <Button
          onClick={() => setCount((value) => value + 1)}
          variant="outline"
        >
          Add
        </Button>
        <Button onClick={() => setCount(0)} variant="ghost">
          Clear
        </Button>
      </div>
    </div>
  );
};
```

## Installation

With the shadcn CLI:

```bash
pnpm dlx shadcn@latest add @alexandrejoly/notification-badge
```

Without the `@alexandrejoly` namespace, pass the registry URL:

```bash
pnpm dlx shadcn@latest add https://alexandrejoly.me/r/notification-badge.json
```

Or by hand:

1. Install the dependencies:

   ```bash
   pnpm add class-variance-authority
   ```
2. Copy the source into `components/ui/notification-badge.tsx`, then update the import paths to match your aliases:

   ```tsx
   "use client";

   import { cva } from "class-variance-authority";
   import type { VariantProps } from "class-variance-authority";
   import { createContext, use, useMemo } from "react";

   import { cn } from "@/lib/utils";

   const NotificationBadgeContext = createContext<{
     value?: number | string;
     visible: boolean;
   }>({ visible: false });

   const notificationBadgeVariants = cva(
     "group/notification-badge relative inline-flex [--hole-arc-left:calc(100%_+_var(--hole-right)_-_var(--hole-w)_+_var(--hole-h)/2)] [--hole-arc-radius:calc(var(--hole-h)/2_+_var(--hole-gap))] [--hole-arc-right:calc(100%_+_var(--hole-right)_-_var(--hole-h)/2)] [--hole-center-y:calc(var(--hole-h)/2_+_var(--hole-top))] [--hole-gap:1.5px] [--hole-top:calc(0.125rem_-_var(--hole-h)/2_+_var(--hole-radius,0px)_*_0.293)]",
     {
       defaultVariants: { variant: "default" },
       variants: {
         variant: {
           default:
             "[--hole-h:1rem] [--hole-right:calc(var(--hole-w)/2_-_var(--hole-radius,0px)_*_0.293)] [--hole-w:calc(var(--hole-h)_+_(var(--hole-chars,1)_-_1)_*_0.5rem)]",
           dot: "[--hole-h:0.5rem] [--hole-right:calc(var(--hole-w)/2_-_0.125rem_-_var(--hole-radius,0px)_*_0.293)] [--hole-w:0.5rem]",
         },
       },
     }
   );

   function NotificationBadge({
     className,
     style,
     value,
     variant,
     ...props
   }: React.ComponentProps<"span"> &
     VariantProps<typeof notificationBadgeVariants> & {
       value: number | string;
     }) {
     const isDot = variant === "dot";
     const visible = typeof value === "number" ? value > 0 : value !== "";
     const context = useMemo(
       () => ({ value: isDot ? undefined : value, visible }),
       [isDot, value, visible]
     );

     return (
       <NotificationBadgeContext value={context}>
         <span
           className={cn(notificationBadgeVariants({ variant }), className)}
           data-slot="notification-badge"
           data-visible={visible || undefined}
           style={
             {
               "--hole-chars": String(value).length,
               ...style,
             } as React.CSSProperties
           }
           {...props}
         />
       </NotificationBadgeContext>
     );
   }

   function NotificationBadgeContent({
     className,
     ...props
   }: React.ComponentProps<"span">) {
     return (
       <span
         className={cn(
           "inline-flex",
           "group-data-visible/notification-badge:[clip-path:shape(evenodd_from_-9999px_-9999px,hline_to_calc(100%_+_9999px),vline_to_calc(100%_+_9999px),hline_to_-9999px,close,move_to_var(--hole-arc-right)_calc(var(--hole-center-y)_-_var(--hole-arc-radius)),arc_to_var(--hole-arc-right)_calc(var(--hole-center-y)_+_var(--hole-arc-radius))_of_var(--hole-arc-radius)_cw,hline_to_var(--hole-arc-left),arc_to_var(--hole-arc-left)_calc(var(--hole-center-y)_-_var(--hole-arc-radius))_of_var(--hole-arc-radius)_cw,close)]",
           className
         )}
         data-slot="notification-badge-content"
         {...props}
       />
     );
   }

   function NotificationBadgeIndicator({
     className,
     ...props
   }: Omit<React.ComponentProps<"span">, "children">) {
     const { value, visible } = use(NotificationBadgeContext);
     if (!visible) {
       return null;
     }
     return (
       <span
         aria-hidden
         className={cn(
           "bg-destructive absolute top-(--hole-top) -right-(--hole-right) inline-flex h-(--hole-h) w-(--hole-w) items-center justify-center rounded-full text-xs font-medium whitespace-nowrap text-white forced-colors:bg-[CanvasText] forced-colors:text-[Canvas]",
           className
         )}
         data-slot="notification-badge-indicator"
         {...props}
       >
         {value}
       </span>
     );
   }

   export {
     NotificationBadge,
     NotificationBadgeContent,
     NotificationBadgeIndicator,
   };
   ```

## Usage

```tsx
import {
  NotificationBadge,
  NotificationBadgeContent,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";
```

```tsx
<NotificationBadge value={3}>
  <NotificationBadgeContent>
    <Button aria-label="Notifications, 3 unread" size="icon" variant="outline">
      <BellIcon />
    </Button>
  </NotificationBadgeContent>
  <NotificationBadgeIndicator />
</NotificationBadge>
```

## Examples

### Dot

A dot signals that something is new, without a number.

```tsx
import { EnvelopeIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  NotificationBadge,
  NotificationBadgeContent,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";

export const NotificationBadgeDot = () => (
  <NotificationBadge value={2} variant="dot">
    <NotificationBadgeContent>
      <Button
        aria-label="Messages, new activity"
        size="icon-lg"
        variant="outline"
      >
        <EnvelopeIcon />
      </Button>
    </NotificationBadgeContent>
    <NotificationBadgeIndicator />
  </NotificationBadge>
);
```

### Counts

One character makes a circle, more make a pill, and the hole under it, the transparent cut-out around the badge, keeps the same shape. The demo caps the count at `99+` itself, since `value` shows whatever it gets.

> Cutting the gap around the badge needs the CSS `shape()` function. Without that support, the badge has no gap.

```tsx
import { BellIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  NotificationBadge,
  NotificationBadgeContent,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";

const COUNTS = [7, 42, 99, 128];
const MAX_COUNT = 99;

const toBadgeValue = (count: number) =>
  count > MAX_COUNT ? `${MAX_COUNT}+` : count;

const toUnreadLabel = (count: number) => {
  const shownCount = count > MAX_COUNT ? `more than ${MAX_COUNT}` : count;
  return `Notifications, ${shownCount} unread`;
};

export const NotificationBadgeCounts = () => (
  <div className="flex flex-wrap items-center justify-center gap-8">
    {COUNTS.map((count) => (
      <NotificationBadge key={count} value={toBadgeValue(count)}>
        <NotificationBadgeContent>
          <Button
            aria-label={toUnreadLabel(count)}
            size="icon-lg"
            variant="outline"
          >
            <BellIcon />
          </Button>
        </NotificationBadgeContent>
        <NotificationBadgeIndicator />
      </NotificationBadge>
    ))}
  </div>
);
```

### Compact counts

`value` takes a string, so `Intl.NumberFormat` with `notation: "compact"` can shorten a large count to `1.2K`. The demo pins `"en"` so the server and the browser format alike; pass the page’s locale instead. The label keeps the full number.

```tsx
import { ChatCircleIcon } from "@phosphor-icons/react";
import { Button } from "@/components/ui/button";
import {
  NotificationBadge,
  NotificationBadgeContent,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";

const COUNTS = [1234, 56_789];

const compactFormat = new Intl.NumberFormat("en", { notation: "compact" });
const fullFormat = new Intl.NumberFormat("en");

export const NotificationBadgeCompact = () => (
  <div className="flex flex-wrap items-center justify-center gap-8">
    {COUNTS.map((count) => (
      <NotificationBadge key={count} value={compactFormat.format(count)}>
        <NotificationBadgeContent>
          <Button
            aria-label={`Comments, ${fullFormat.format(count)} unread`}
            size="icon-lg"
            variant="outline"
          >
            <ChatCircleIcon />
          </Button>
        </NotificationBadgeContent>
        <NotificationBadgeIndicator />
      </NotificationBadge>
    ))}
  </div>
);
```

### Beside a label

Beside a label, `NotificationBadgeContent` is left out and `static` takes the indicator out of the overlay.

```tsx
import { Button } from "@/components/ui/button";
import {
  NotificationBadge,
  NotificationBadgeIndicator,
} from "@/components/ui/notification-badge";

export const NotificationBadgeLabel = () => (
  <Button variant="outline">
    Inbox
    <NotificationBadge value={12}>
      <NotificationBadgeIndicator className="static" />
    </NotificationBadge>
    <span className="sr-only">, 12 unread</span>
  </Button>
);
```

## Accessibility

The indicator is `aria-hidden`, since its count, read alone, doesn’t say what it counts. Put the count in the name of the button instead.

On an icon button, write it in the `aria-label`: “Notifications, 3 unread”, or “Messages, new activity” for a dot, which shows no count at all. Beside a label, keep the visible text first and add the rest in an `sr-only` span, so the name still starts with what the button shows.

Build the whole phrase with your translation library rather than joining pieces, and say “more than 99” where you show `99+`.

A count that changes is not announced. To announce it, update one polite live region for the whole app with a phrase that says what changed, such as “2 new messages”.

In forced colors mode, such as Windows high contrast, the badge takes the system text color, so even a dot stays visible.

## API reference

### NotificationBadge

Shares the value with its parts. Sets the geometry of the hole, the transparent cut-out around the badge, as CSS variables.

Takes every prop of `span`.

| Prop or variable | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `number \| string` | — | What the badge shows, as is: format it yourself, as `99+` or a compact number. Nothing shows for a number of 0 or below, or an empty string; a string such as `"0"` still shows. The badge widens by one step per character. |
| `variant` | `"default" \| "dot"` | `"default"` | A counter, or a dot shown while `value` would show. |
| `--hole-radius` | `CSS length` | `0px` | The corner radius of the content, so the badge sits on its curve: `50%` for an avatar. |
| `--hole-gap` | `CSS length` | `1.5px` | The transparent gap around the badge. |
| `--hole-top, --hole-right` | `CSS length` | — | Moves the badge and its hole together, from the top and right edges. |

### NotificationBadgeContent

Wraps the icon, button or avatar. Clips a hole under the badge so the gap shows whatever is behind it.

Takes every prop of `span`.

### NotificationBadgeIndicator

A badge in the `destructive` color: a circle for one character, a pill for more, sized and placed from the root’s variables. Renders nothing while `value` is 0 or below, or empty, and is `aria-hidden`: see Accessibility. Add `static` to its class to sit it beside a label instead of over the content.

Takes every prop of `span`.

[All components](https://alexandrejoly.me/components.md)
