Display
Notification Badge
A count bubble that wraps any element — rolling digits, a bump on change, max overflow, dot mode with a ping, and four corner placements.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/notification-badge.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "NotificationBadge" wrapper component
using class-variance-authority (cva).
Contract
- Export a forwardRef span extending React.HTMLAttributes<HTMLSpanElement>
(minus children) plus VariantProps of the badge cva.
- children: React.ReactNode — the element being decorated (a bell button, an
avatar, a tab label). The wrapper adds position only; it never restyles or
clones the child.
- count?: number, max?: number (default 99), dot?: boolean (default false),
showZero?: boolean (default false),
placement?: "top-right" | "top-left" | "bottom-right" | "bottom-left"
(default "top-right"), variant?: "default" | "destructive" | "success"
(default "default"), offset?: { x: number; y: number },
label?: string (the screen-reader sentence), className merged via cn().
Behavior
- Visibility: with a count, render when count > 0, or when showZero is true;
without a count, only `dot` renders anything. The child always renders either
way — hiding the badge must never hide what it decorates.
- Overflow: counts above `max` render as "<max>+" (99+), while the screen-reader
sentence keeps the exact number.
- Count change: the outgoing digits roll out and the incoming digits roll in,
upward when the count grew and downward when it shrank, and the whole bubble
plays a single spring-ish bump (scale 1 → 1.3 → 0.94 → 1). Implement the roll
with two @keyframes that read a --shift CSS variable so one pair covers both
directions, and drive the bump by remounting the bubble with a counter key so
back-to-back changes always replay. Ship the keyframes inside the component
through a React 19 hoisted <style href precedence> tag.
- The outgoing digits are absolutely positioned inside an overflow-hidden
bubble and remove themselves in onAnimationEnd — no timers to leak.
- dot mode: a small circle with an animate-ping clone behind it (bg-inherit so
it follows the variant); the clone is motion-reduce:hidden and the bubble is
never overflow-hidden in this mode, so the pulse is not clipped.
- prefers-reduced-motion: check window.matchMedia at change time and jump
straight to the final digits — no roll element is created at all — and the
bump keyframe is neutralised by motion-reduce:[animation:none].
- Digit width: tabular-nums plus a min-width equal to the bubble height, so
1 → 7 never resizes the bubble and 9 → 10 grows it by exactly one figure.
Rendering & styling
- Root: relative inline-flex shrink-0 — it wraps tightly around the child.
- The badge sits in an absolutely positioned layer anchored to the chosen
corner and nudged by half its size with
transform: translate(calc(±50% + offset.x px), calc(±50% + offset.y px));
keeping the transform on that layer leaves the bubble's own transform free
for the bump animation.
- The whole badge layer is aria-hidden AND pointer-events-none: it is
decoration that must never intercept the child's clicks, and the count is
announced once through an sr-only sentence rendered beside the child
("3 notifications", overridable via `label`).
- Bubble: rounded-full, font-semibold, leading-none, tabular-nums, plus
ring-2 ring-background so it separates cleanly from busy children like
avatars. Sizes: size-2.5 for the dot, h-5 min-w-5 px-1.5 text-[11px] for
counts.
- Variants are semantic tokens only: default bg-primary/text-primary-foreground,
destructive bg-destructive/text-background, success bg-chart-2/text-background.
No hex, no rgb.
Customization levers
- Placement + offset: the four corners cover square children; use offset to
push the bubble a couple of pixels back over a circular avatar.
- Scale: the bubble's h-5 / min-w-5 / text-[11px] triple moves together — bump
all three for a chunkier badge, or shrink the dot to size-2 for dense nav.
- Variant palette: add an entry to the cva variant map pointing at another
chart token; keep the paired foreground so contrast survives dark mode.
- Motion budget: bump duration/overshoot and roll duration are independent —
soften to a plain fade by swapping the roll keyframes for opacity-only ones,
or drop the bump entirely and keep the roll.
- Announcements: pass `label` for domain wording ("3 unread messages"), or wrap
the sr-only sentence in aria-live="polite" if the count changes while the
user is elsewhere on the page.
- Separator ring: ring-background assumes the badge sits on the page surface —
switch to ring-card inside cards, or drop the ring on flat backgrounds.Concepts
- Wrapper, not a layout — the component only adds
position: relativearound whatever you pass it; the child keeps its own size, its own handlers and its own styling, so the badge can decorate a button, an image or a plain label unchanged. - Decoration that can't steal clicks — the badge layer is
pointer-events-noneandaria-hidden, which is what keeps a bell button fully clickable through its own corner and stops the number being announced twice. - Overflow clamp — visually a count saturates at
max("99+") because the bubble has a width budget, while the sr-only sentence keeps the true number for anyone who needs it. - Rolling digits — the old value slides out and the new one slides in from the direction the number moved, so "went up" and "went down" are readable at a glance; the outgoing node removes itself in
onAnimationEndrather than on a timer. - Remount key as replay — CSS animations only fire when an element mounts or gains the class, so an incrementing key on the bubble guarantees a change that arrives mid-animation still bumps.
- Ping as "something is new" — dot mode trades the number for a pulsing ring: one bit of information, no precision implied, and it stands still under
prefers-reduced-motion.