Skip to content

Notification Badge

A diagonal slide, spring pop and rolling count.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/notification-badge.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Add the theme tokens

    This component uses --success and --success-foreground, which shadcn does not define by default. Add them to your global CSS:

    app/globals.css
    :root {
      --success: #15803d;
      --success-foreground: #ffffff;
    }
    
    .dark {
      --success: #4ade80;
      --success-foreground: #052e16;
    }
    
    @theme inline {
      --color-success: var(--success);
      --color-success-foreground: var(--success-foreground);
    }
  4. Copy the source

    components/ui/notification-badge/notification-badge.tsx
    "use client";
    
    import { useState, type ComponentProps, type ReactNode } from "react";
    import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
    import { cn } from "@/lib/utils";
    import { duration, ease } from "@/lib/motion";
    
    export interface NotificationBadgeProps
      extends Omit<ComponentProps<"span">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** The element the badge sits on, e.g. an icon button. */
      children: ReactNode;
      /** Number to show. The badge hides at 0 unless showZero is set. @default 0 */
      count?: number;
      /** Counts above this show as "99+". @default 99 */
      max?: number;
      /** Show a dot instead of a number. @default false */
      dot?: boolean;
      /** Keep the badge visible at 0. @default false */
      showZero?: boolean;
      /** Add a soft, repeating ring to draw attention. @default false */
      pulse?: boolean;
      /** Badge color. @default "destructive" */
      tone?: "destructive" | "primary" | "success";
      /** Screen-reader announcement when the count changes. @default (n) => `${n} notifications` */
      label?: (count: number) => string;
    }
    
    const tones = {
      destructive: "bg-destructive text-white",
      primary: "bg-primary text-primary-foreground",
      success: "bg-success text-success-foreground",
    } as const;
    
    const ringTones = { destructive: "bg-destructive", primary: "bg-primary", success: "bg-success" } as const;
    
    export function NotificationBadge({
      children,
      count = 0,
      max = 99,
      dot = false,
      showZero = false,
      pulse = false,
      tone = "destructive",
      label = (n) => `${n} notification${n === 1 ? "" : "s"}`,
      className,
      ...props
    }: NotificationBadgeProps) {
      const reduceMotion = useReducedMotion();
      const visible = dot || count > 0 || showZero;
      const text = count > max ? `${max}+` : String(count);
    
      // Digits roll up when the count rises and down when it falls.
      const [previous, setPrevious] = useState(count);
      const [direction, setDirection] = useState(1);
      if (previous !== count) {
        setDirection(count > previous ? 1 : -1);
        setPrevious(count);
      }
    
      const roll: Variants = reduceMotion
        ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
        : {
            initial: (dir: number) => ({ y: dir > 0 ? "90%" : "-90%", opacity: 0 }),
            animate: { y: "0%", opacity: 1 },
            exit: (dir: number) => ({ y: dir > 0 ? "-90%" : "90%", opacity: 0 }),
          };
    
      return (
        <span className={cn("relative inline-flex", className)} {...props}>
          {children}
          <AnimatePresence>
            {visible ? (
              <motion.span
                key="badge"
                aria-hidden="true"
                layout={!reduceMotion}
                // Slides in diagonally from the corner and pops with a spring.
                initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.3, x: 6, y: -6 }}
                animate={{ opacity: 1, scale: 1, x: 0, y: 0 }}
                exit={
                  reduceMotion
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.3, x: 6, y: -6, transition: { duration: duration.quick, ease: ease.out } }
                }
                transition={reduceMotion ? { duration: duration.quick } : { type: "spring", stiffness: 500, damping: 18 }}
                style={{ borderRadius: 999 }}
                className={cn(
                  "pointer-events-none absolute flex items-center justify-center ring-2 ring-background",
                  dot ? "-top-0.5 -right-0.5 size-2.5" : "-top-1.5 -right-1.5 h-5 min-w-5 px-1.5 text-[11px] leading-none font-semibold tabular-nums",
                  tones[tone],
                )}
              >
                {pulse ? (
                  <motion.span
                    className={cn("absolute inset-0 rounded-full", ringTones[tone])}
                    initial={{ opacity: 0.55, scale: 1 }}
                    animate={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 2.4 }}
                    transition={{ duration: 1.4, ease: ease.out, repeat: reduceMotion ? 0 : Infinity, repeatDelay: 0.4 }}
                  />
                ) : null}
                {dot ? null : (
                  <span className="relative inline-flex h-full items-center overflow-hidden">
                    <AnimatePresence mode="popLayout" initial={false} custom={direction}>
                      <motion.span
                        key={text}
                        custom={direction}
                        variants={roll}
                        initial="initial"
                        animate="animate"
                        exit="exit"
                        transition={{ duration: duration.quick + 0.04, ease: ease.out }}
                        layout={reduceMotion ? false : "position"}
                        className="inline-block"
                      >
                        {text}
                      </motion.span>
                    </AnimatePresence>
                  </span>
                )}
              </motion.span>
            ) : null}
          </AnimatePresence>
          <span role="status" className="sr-only">
            {visible && !dot ? label(count) : ""}
          </span>
        </span>
      );
    }

Usage

import { NotificationBadge } from "@/components/ui/notification-badge/notification-badge";

export function BellButton({ unread }: { unread: number }) {
  return (
    <NotificationBadge count={unread}>
      <button aria-label={`Notifications, ${unread} unread`}>
        <BellIcon />
      </button>
    </NotificationBadge>
  );
}

API reference

PropTypeDefaultDescription
children*ReactNode–The element the badge sits on, e.g. an icon button.
countnumber0Number to show. The badge hides at 0 unless showZero is set.
maxnumber99Counts above this show as "99+".
dotbooleanfalseShow a dot instead of a number.
showZerobooleanfalseKeep the badge visible at 0.
pulsebooleanfalseAdd a soft, repeating ring to draw attention.
tone"destructive" | "primary" | "success""destructive"Badge color.
label(count: number) => string(n) => `${n} notifications`Screen-reader announcement when the count changes.

Also accepts every native <span> attribute, including ref and className.

  • The badge slides in diagonally from the corner and pops on a low-damping spring; its width morphs as digits are added.
  • Counts above max show as "99+".

Accessibility

  • The badge itself is aria-hidden; put the count in the wrapped control's accessible name, as in the examples.
  • A polite status region announces the new count when it changes (customise the wording with label).
  • Status is never conveyed by color alone: numbers are shown as text, and dots should be paired with a label such as "online".
  • With reduced motion enabled, the badge fades in and out without sliding, the count cross-fades and the pulse is off.