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…

"use client"

import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"

/**
 * Keyframes ship with the component via React 19 hoisted <style> —
 * no Tailwind config edits, and duplicates dedupe by href.
 * The roll direction is passed in as --znb-shift so one pair of keyframes
 * serves both counting up and counting down.
 */
const KEYFRAMES = `@keyframes znb-bump{0%{transform:scale(1)}35%{transform:scale(1.3)}70%{transform:scale(0.94)}100%{transform:scale(1)}}
@keyframes znb-roll-in{from{transform:translateY(var(--znb-shift));opacity:0}to{transform:translateY(0);opacity:1}}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/notification-badge.json

Prompt

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: relative around 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-none and aria-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 onAnimationEnd rather 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.

On This Page