Notification badge
Free · MITCount 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.
Installation
$ npx shadcn@latest add https://goodui.dev/r/notification-badge.jsonThe 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
- 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.
- 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 need | Use |
|---|---|
| Exact count | count={n} (caps at 99+ by default) |
| Just "there is something" | dot |
| Urgent, keep pinging | pulse |
| Calm, on-brand count | tone="primary" |
| Show 0 instead of hiding | showZero |
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
| Prop | Type | Default |
|---|---|---|
| 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+". | number | 99 |
| dot A plain dot instead of a number. | boolean | false |
| pulse A ring that keeps pinging outward while the count is above zero. | boolean | false |
| showZero Keep the badge visible at 0. | boolean | false |
| 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
One badge, every layer: solid, soft, outline, status dot, gradient, glass and neo in six tones, with pulse, shine, bounce-in and count-pop animations, icons, removable chips and clickable filters.
Self-contained toaster with toast() API: cards stack and expand on hover, swipe away with a drag, morph from loading to success or error for promises, and pause their timers on hover.
Mobile bottom bar where a pill glides to the selected tab and its icon bounces, in a labelled or expanding variant, with popping badges and an optional raised center action.
macOS-style dock whose tiles magnify on a spring as the pointer nears, with tooltips, running dots, badges and a launch bounce, falling back to a simple press scale on touch.