Notification badge

Free · MIT

Count badge for icon buttons that springs in, bumps when the count rises, rolls its digits, caps at 99+ and offers a pulse ring or dot variant.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/notification-badge.json

The file lands in components/goodui/notification-badge.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/notification-badge works too.

Usage

import { Bell } from "lucide-react"
import { NotificationBadge } from "@/components/goodui/notification-badge"

export function BookingRequestsButton({ count, onOpen }: { count: number; onOpen: () => void }) {
  return (
    <NotificationBadge count={count} pulse label="booking requests">
      <button
        type="button"
        aria-label="Booking requests"
        onClick={onOpen}
        className="grid size-10 place-items-center rounded-full hover:bg-muted"
      >
        <Bell className="size-5" />
      </button>
    </NotificationBadge>
  )
}

When to use

Use it for
  • An unread or pending count on an icon button: booking requests on a bell, emails on an inbox icon.
  • A simple "something new" dot on a messages icon (dot).
  • Counts that rise while the user watches, where the bump draws the eye without a toast.
Reach for something else
  • A standalone status or count label in text: use badge.
  • Telling the user what happened, not just how many: use toast-stack.
  • Counts on mobile tab bar items: bottom-nav and dock have their own badges.
If you needUse
Exact countcount={n} (caps at 99+ by default)
Just "there is something"dot
Urgent, keep pingingpulse
Calm, on-brand counttone="primary"
Show 0 instead of hidingshowZero

Examples

Dot for new messages

<NotificationBadge count={hasNew ? 1 : 0} dot tone="emerald" label="new messages">
  <button type="button" aria-label="Messages"><MessageSquare /></button>
</NotificationBadge>

Large inbox with a custom cap

<NotificationBadge count={1240} max={999} tone="primary" label="emails">
  <button type="button" aria-label="Inbox"><Inbox /></button>
</NotificationBadge>

API reference

PropTypeDefault
children*
The element the badge sits on, usually an icon button.
React.ReactNode—
count*
Number shown in the badge. At 0 the badge hides (unless showZero); a higher count bumps it.
number—
max
Above this the badge reads "99+".
number99
dot
A plain dot instead of a number.
booleanfalse
pulse
A ring that keeps pinging outward while the count is above zero.
booleanfalse
showZero
Keep the badge visible at 0.
booleanfalse
tone
Badge color: red (rose) for alerts, primary for your theme color, emerald or amber.
"red" | "primary" | "emerald" | "amber""red"
placement
Badge corner. Left and right are mirrored under RTL (right means the inline end).
"top-right" | "top-left" | "bottom-right""top-right"
label
Words for screen readers after the count.
string"unread"
labels
Text for built-in controls and screen readers; pass translations here.
Partial<NotificationBadgeLabels>—
badgeClassName
Classes for the badge itself; className goes on the wrapper around children.
string—

Also accepts every <span> attribute.

Accessibility

  • The visible badge is aria-hidden; an sr-only polite live region reads "3 booking requests", or "More than 99 …" above max, and is empty at 0.
  • label is the word after the count (default "unread"); pass a translated phrase. Translate the capped wording with labels.moreThan.
  • Give the wrapped button its own aria-label; the badge does not label it.

Motion

  • The badge springs in from scale 0 when the count leaves 0 and springs out when it returns (stiffness 520, damping 22).
  • When the count goes up from a non-zero value, it swells to 1.3x in 100 ms and settles back on a bouncy spring (stiffness 520, damping 13). Digits roll through number-flow.
  • pulse adds a ring that pings outward every 1.6 s while the count is above 0.
  • Reduced motion: the badge fades in and out, there is no bump or ping, and the digits snap.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • Wrap the icon button as children; the badge positions itself on its corner. Do not position it yourself.
  • count is a number; the component formats "99+" from max. Do not pass "99+" strings.
  • label is the screen reader noun ("booking requests"), not the button's name.
  • dot still announces the count to screen readers, so pass a real count (1) rather than true.
  • placement is mirrored under RTL: top-right sits on the inline end (the left corner in RTL).
  • labels={{ moreThan: (max) => `Mehr als ${max}` }} translates the screen-reader text for capped counts.

FAQ

Why doesn't it bump when the count goes from 0 to 1?

Going from 0 the badge springs in instead; the bump only plays when an already visible count rises.

Does it animate on first render?

No. AnimatePresence has initial={false}, so a badge that is there on mount just appears.

Related