Skip to content

A count that rolls its digits, pops when it rises and caps at 99+.

Notificationsmotion@number-flow/react

01Preview

NorthwindMCNew updates on your profile

102 unread

02Install

Copy the source into your project. It becomes yours: no package to update, no wrapper between you and the markup. It needs:

npm install motion @number-flow/react

03Usage

import { CountBadge } from "@/components/ui/count-badge";

// Inside the button, so the count becomes part of its name.
<button className="relative">
  <span className="sr-only">Notifications</span>
  <BellIcon />
  <CountBadge count={unread} position="top-right" label={(n) => `${n} unread`} />
</button>

// Wrapping an avatar, pulled in to its rim.
<CountBadge count={1} dot overlap="circular">
  <Avatar name="Maya Chen" />
</CountBadge>

04Source

"use client";
import NumberFlow from "@number-flow/react";
import { AnimatePresence, motion, useAnimate, useReducedMotion } from "motion/react";
import { useEffect, useRef } from "react";
import { cn } from "@/lib/cn";
import { ease, spring } from "@/lib/motion";

type Size = "sm" | "md";
type Tone = "solid" | "danger" | "muted";
type Position = "inline" | "top-right" | "bottom-right";

const expo = `cubic-bezier(${ease.out.join(",")})`;
const digitTiming = {
  transformTiming: { duration: 420, easing: expo },
  spinTiming: { duration: 420, easing: expo },
  opacityTiming: { duration: 160, easing: "ease-out" },
};

export type CountBadgeProps = Omit<React.ComponentProps<"span">, "children"> & {
  /** The number to show. Nothing renders at 0 unless `showZero`. */
  count: number;
  /** Above this, the badge reads "99+" while the accessible label keeps the real number. */
  max?: number;
  /** A plain dot instead of digits, for "something new" without a number. */
  dot?: boolean;
  showZero?: boolean;
  size?: Size;
  /** Foreground fill (the quiet default), danger for things that need action, or muted for counts in a list. */
  tone?: Tone;
  /** Where it sits on its anchor. Defaults to top-right when it wraps children, inline otherwise. */
  position?: Position;
  /** Pull the badge in to sit on the edge of a round anchor like an avatar, not its empty bounding-box corner. */
  overlap?: "rectangular" | "circular";
  /** What a screen reader hears. Rendered visually hidden inside the badge, so a badge inside a button becomes part of its name. */
  label?: (count: number) => string;
  /** Also announce changes through a polite live region. For counts that change while the person is elsewhere. */
  announce?: boolean;
  /** The element the badge sits on: an icon button, an avatar. */
  children?: React.ReactNode;
};

const defaultLabel = (n: number) => `${n} new`;

const sizes = {
  sm: { pill: "h-4 min-w-4 px-1 text-[10px]", dot: "size-2" },
  md: { pill: "h-[18px] min-w-[18px] px-[5px] text-[10.5px]", dot: "size-2.5" },
} as const;

const tones = {
  solid: "bg-fg text-frame",
  danger: "bg-danger text-frame",
  muted: "bg-hover text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]",
} as const;

// Anchored badges are centered on the anchor's corner. On a circle the corner is
// empty space, so the center moves in to the rim at 45°: r(1 − 1/√2) ≈ 14.6%.
const anchors = {
  rectangular: { "top-right": "right-0 top-0 translate-x-1/2 -translate-y-1/2", "bottom-right": "right-0 bottom-0 translate-x-1/2 translate-y-1/2" },
  circular: { "top-right": "right-[14.6%] top-[14.6%] translate-x-1/2 -translate-y-1/2", "bottom-right": "right-[14.6%] bottom-[14.6%] translate-x-1/2 translate-y-1/2" },
} as const;

export function CountBadge({
  count,
  max = 99,
  dot = false,
  showZero = false,
  size = "md",
  tone = "solid",
  position,
  overlap = "rectangular",
  label = defaultLabel,
  announce = false,
  children,
  className,
  ...rest
}: CountBadgeProps) {
  const reduce = useReducedMotion();
  const [scope, animate] = useAnimate<HTMLSpanElement>();
  const [ringScope, animateRing] = useAnimate<HTMLSpanElement>();
  const previous = useRef(count);
  const where: Position = position ?? (children ? "top-right" : "inline");
  const anchored = where !== "inline";
  const visible = dot ? count > 0 : count > 0 || showZero;
  const over = count > max;

  // A rise the person didn't cause gets a pop: up 18% in 90ms, then a spring back.
  // Falling counts (they read something) and the first appearance don't pop.
  useEffect(() => {
    const was = previous.current;
    previous.current = count;
    if (reduce || count <= was || was <= 0) return;
    if (scope.current) {
      animate(scope.current, { scale: 1.18 }, { duration: 0.09, ease: ease.out }).then(() => {
        if (scope.current) animate(scope.current, { scale: 1 }, spring.pop);
      });
    }
    // A dot has no digits to roll, so it sends out one soft ring instead.
    if (dot && ringScope.current) {
      animateRing(ringScope.current, { scale: [1, 2.6], opacity: [0.5, 0] }, { duration: 0.7, ease: ease.out });
    }
  }, [count, dot, reduce, animate, animateRing, scope, ringScope]);

  const text = visible ? label(count) : "";

  const badge = (
    <span
      data-slot="count-badge"
      data-size={size}
      data-tone={tone}
      data-state={visible ? "visible" : "hidden"}
      className={cn(
        "pointer-events-none select-none",
        anchored ? cn("absolute z-[1]", anchors[overlap][where]) : "relative inline-flex align-middle",
        !children && className,
      )}
      {...(children ? {} : rest)}
    >
      <AnimatePresence initial={false}>
        {visible && (
          <motion.span
            key="badge"
            aria-hidden
            className="relative block"
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4, filter: "blur(2px)" }}
            animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
            exit={reduce ? { opacity: 0, transition: { duration: 0.12 } } : { opacity: 0, scale: 0.4, filter: "blur(2px)", transition: { duration: 0.16, ease: ease.in } }}
            transition={reduce ? { duration: 0.15 } : spring.pop}
          >
            {dot ? (
              <span ref={scope} className="relative block">
                <span ref={ringScope} className={cn("absolute inset-0 rounded-full opacity-0", tones[tone].split(" ")[0])} />
                <span
                  className={cn(
                    "relative block rounded-full",
                    sizes[size].dot,
                    tones[tone],
                    anchored && "shadow-[0_0_0_2px_var(--badge-cutout,var(--frame))]",
                  )}
                />
              </span>
            ) : (
              <span
                ref={scope}
                className={cn(
                  "inline-flex items-center justify-center overflow-hidden rounded-full font-medium leading-none tracking-normal tabular",
                  sizes[size].pill,
                  tones[tone],
                  anchored && "shadow-[0_0_0_2px_var(--badge-cutout,var(--frame))]",
                )}
              >
                <NumberFlow
                  value={over ? max : count}
                  suffix={over ? "+" : undefined}
                  {...digitTiming}
                />
              </span>
            )}
          </motion.span>
        )}
      </AnimatePresence>
      <span className="sr-only" aria-live={announce ? "polite" : undefined}>
        {text}
      </span>
    </span>
  );

  if (!children) return badge;
  return (
    <span data-slot="count-badge-anchor" className={cn("relative inline-flex shrink-0 align-middle", className)} {...rest}>
      {children}
      {badge}
    </span>
  );
}

05Props

PropTypeDefaultDescription
count*numberThe number to show. Hidden at 0 unless showZero.
maxnumber99Above this the badge reads 99+. The accessible label keeps the real number.
dotbooleanfalseA dot instead of digits, shown while count is above 0.
showZerobooleanfalseKeep a 0 on screen instead of scaling out.
size"sm" | "md""md"16px or 18px tall; the dot is 8px or 10px.
tone"solid" | "danger" | "muted""solid"Foreground fill, danger for things that need action, or a quiet outlined count for lists.
position"inline" | "top-right" | "bottom-right""top-right" with children, else "inline"Anchored corner, or inline in a row. Anchored badges position against the nearest positioned ancestor.
overlap"rectangular" | "circular""rectangular"Circular pulls the badge in to the rim of a round anchor.
label(count: number) => string(n) => `${n} new`Visually hidden text inside the badge, read as part of whatever contains it.
announcebooleanfalseAnnounce changes through a polite live region.
childrenReact.ReactNodeThe anchor to wrap: an avatar, an icon.

06Notes

Behavior

  • Only rises pop. A falling count is the person reading things, so it rolls down quietly, and the first appearance from 0 gets its entrance instead of a pop.
  • Past max it reads 99+, and still pops on every new arrival so a busy inbox keeps signalling. Screen readers get the real number.
  • At 0 it scales out showing the last number it had, rather than rolling to 0 first and then leaving.
  • Anchored badges draw a 2px cutout ring from --badge-cutout (default --frame), so set it to the surface the anchor sits on.

Motion

  • Digits roll in 420ms on the expo ease-out, clipped to the pill. The pill widens with them, so 9 to 10 grows rather than jumps.
  • A rise scales the badge to 1.18 in 90ms, then springs back on the pop spring (600/30). A dot sends out one ring to 2.6× over 700ms instead.
  • Enter and exit scale between 0.4 and 1 with a 2px blur; the exit takes 160ms. Reduced motion swaps all of it for a 150ms fade and digits that change in place.

Accessibility

  • The drawn badge is aria-hidden. Its label renders as visually hidden text, so a badge inside a button reads as "Notifications 3 unread". Name that button with text (sr-only), not aria-label, which would replace the count.
  • With announce, the label sits in a polite live region for counts that change while focus is elsewhere.
  • Color is never the only signal: danger badges still carry the number, and dots carry a label.