Skip to content

Badge Ping Counter

A notification badge that earns its number: the count rolls like a counter wheel, the pill pops, two rings ping outward and the bell swings on every increment. 99+ overflow, dot mode, any icon or avatar, light and dark. No dependencies.

DOM + CSS0 deps1 file · 11 KB TSXreduced-motion aware

Freeplayfulminimal
Preview

Props

3
99
Dot mode
No runtime dependencies
"use client";

import { useState, type CSSProperties, type ReactNode } from "react";

export type BadgePingCounterProps = {
  /** Starting count for the uncontrolled counter. Default 0. */
  initialCount?: number;
  /** Controlled count. When set, the component only reports clicks via onCountChange. */
  count?: number;
  /** Called with count + 1 on every press. */
  onCountChange?: (next: number) => void;
  /** Counts above this show as "99+". Default 99. */
  max?: number;
  /** Show a plain dot instead of a number. */
  dot?: boolean;
  /** Built-in icon, or pass children to draw your own (an avatar, a logo). */
  icon?: "bell" | "inbox";
  /** Accessible name of the button. Default "Notifications". */
  label?: string;
  /** Badge colour (hex, rgb or hsl). Default #ef4444. */
  color?: string;
  /** Button diameter in px. Default 44. */
  size?: number;
  /** "light" inherits the page and works on white; "dark" paints pale-on-black. */
  tone?: "light" | "dark";
  /** Custom icon content; replaces the built-in icon. */
  children?: ReactNode;
  className?: string;
};

const COLOR_RE = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([0-9.,%\s/-]+\))$/i;

const CSS = [
  ".bpc{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:none;cursor:pointer;color:var(--bpc-fg,#737373);",
  "background:color-mix(in srgb,currentColor 4%,transparent);border:1px solid color-mix(in srgb,currentColor 24%,transparent);",
  "transition:transform .38s cubic-bezier(.34,1.56,.64,1),background-color .2s,border-color .2s,color .2s;-webkit-tap-highlight-color:transparent;touch-action:manipulation}",
  ".bpc[data-tone=dark]{--bpc-fg:rgba(255,255,255,.74);background:linear-gradient(180deg,rgba(255,255,255,.07),rgba(255,255,255,.02));border-color:rgba(255,255,255,.12);box-shadow:inset 0 1px 0 rgba(255,255,255,.08),0 10px 24px -12px rgba(0,0,0,.8)}",
  ".bpc:hover{background:color-mix(in srgb,currentColor 9%,transparent);border-color:color-mix(in srgb,currentColor 40%,transparent)}",
  ".bpc[data-tone=dark]:hover{background:linear-gradient(180deg,rgba(255,255,255,.11),rgba(255,255,255,.04));border-color:rgba(255,255,255,.22);color:#fff}",
  ".bpc:active{transform:scale(.9);transition-duration:.1s}",
  ".bpc-ico{display:flex;align-items:center;justify-content:center;transform-origin:50% 12%;transition:transform .3s cubic-bezier(.34,1.56,.64,1)}",
  ".bpc:hover .bpc-ico{transform:scale(1.08)}",
  ".bpc-ico[data-s]{animation:bpc-swing .8s cubic-bezier(.36,.07,.19,.97) both}",
  ".bpc-ico[data-s=hop]{animation:bpc-hop .55s cubic-bezier(.34,1.56,.64,1) both}",
  ".bpc-slot{position:absolute;top:calc(-4px*var(--bpc-k,1));right:calc(-4px*var(--bpc-k,1));display:block;z-index:2;pointer-events:none}",
  ".bpc-slot[data-up]{animation:bpc-pop .55s cubic-bezier(.34,1.56,.64,1) both}",
  ".bpc-slot[data-dn]{animation:bpc-dip .32s cubic-bezier(.2,.8,.2,1) both}",
  ".bpc-slot[data-out]{animation:bpc-out .28s cubic-bezier(.4,0,1,1) forwards}",
  ".bpc-pill{position:relative;display:flex;align-items:center;justify-content:center;height:calc(20px*var(--bpc-k,1));min-width:calc(20px*var(--bpc-k,1));padding:0 calc(6px*var(--bpc-k,1));border-radius:999px;box-sizing:border-box;",
  "background:linear-gradient(180deg,color-mix(in srgb,var(--bpc-c) 82%,#fff),var(--bpc-c) 55%,color-mix(in srgb,var(--bpc-c) 82%,#000));",
  "color:#fff;font:700 calc(11px*var(--bpc-k,1))/1 ui-sans-serif,system-ui,sans-serif;font-variant-numeric:tabular-nums;letter-spacing:.01em;",
  "box-shadow:0 0 0 2px var(--bpc-cut,transparent),0 6px 14px -4px color-mix(in srgb,var(--bpc-c) 70%,transparent),inset 0 1px 0 rgba(255,255,255,.4)}",
  ".bpc-pill[data-dot]{height:calc(11px*var(--bpc-k,1));min-width:calc(11px*var(--bpc-k,1));width:calc(11px*var(--bpc-k,1));padding:0}",
  ".bpc-num{position:relative;display:block;overflow:hidden;height:calc(14px*var(--bpc-k,1));line-height:calc(14px*var(--bpc-k,1));text-align:center}",
  ".bpc-num>span{display:block;white-space:nowrap}",
  ".bpc-num[data-roll]>span:first-child{visibility:hidden}",
  ".bpc-num[data-roll]>span+span{position:absolute;left:0;right:0;top:0}",
  ".bpc-in{animation:bpc-up .46s cubic-bezier(.2,.9,.25,1.12) both}",
  ".bpc-in[data-d]{animation-name:bpc-dn}",
  ".bpc-gone{animation:bpc-away .3s cubic-bezier(.4,0,.6,1) both}",
  ".bpc-gone[data-d]{animation-name:bpc-away-d}",
  ".bpc-ring{position:absolute;inset:0;border-radius:999px;pointer-events:none;z-index:-1}",
  ".bpc-ring[data-a]{background:var(--bpc-c);animation:bpc-ping .8s cubic-bezier(0,0,.2,1) both}",
  ".bpc-ring[data-b]{border:2px solid var(--bpc-c);animation:bpc-ping2 1s cubic-bezier(0,0,.2,1) .1s both}",
  ".bpc-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}",
  "@keyframes bpc-pop{0%{transform:scale(.4)}55%{transform:scale(1.28)}100%{transform:scale(1)}}",
  "@keyframes bpc-dip{0%{transform:scale(1)}40%{transform:scale(.82)}100%{transform:scale(1)}}",
  "@keyframes bpc-out{to{transform:scale(0);opacity:0}}",
  "@keyframes bpc-up{from{transform:translateY(120%);opacity:0}to{transform:none;opacity:1}}",
  "@keyframes bpc-dn{from{transform:translateY(-120%);opacity:0}to{transform:none;opacity:1}}",
  "@keyframes bpc-away{to{transform:translateY(-120%);opacity:0}}",
  "@keyframes bpc-away-d{to{transform:translateY(120%);opacity:0}}",
  "@keyframes bpc-ping{0%{transform:scale(1);opacity:.55}100%{transform:scale(2.6,2.2);opacity:0}}",
  "@keyframes bpc-ping2{0%{transform:scale(1);opacity:.7}100%{transform:scale(3.4,2.9);opacity:0}}",
  "@keyframes bpc-swing{0%{transform:rotate(0)}14%{transform:rotate(-17deg)}30%{transform:rotate(13deg)}46%{transform:rotate(-9deg)}62%{transform:rotate(5deg)}80%{transform:rotate(-2deg)}100%{transform:rotate(0)}}",
  "@keyframes bpc-hop{0%{transform:translateY(0) scale(1)}30%{transform:translateY(-12%) scale(1.08)}60%{transform:translateY(3%) scale(.97)}100%{transform:none}}",
  "@keyframes bpc-fade{0%{opacity:.5}100%{opacity:0}}",
  "@media(pointer:coarse){.bpc{min-width:44px;min-height:44px}}",
  "@media(prefers-reduced-motion:reduce){.bpc,.bpc-ico{transition:none}.bpc:active{transform:none}.bpc:hover .bpc-ico{transform:none}",
  ".bpc-ico[data-s],.bpc-slot[data-up],.bpc-slot[data-dn],.bpc-in,.bpc-gone,.bpc-ring[data-b]{animation:none}",
  ".bpc-slot[data-out]{animation:none;opacity:0}.bpc-num>.bpc-gone{display:none}",
  ".bpc-ring[data-a]{animation:bpc-fade .5s ease-out both;transform:scale(1.5)}}",
].join("");

function fmt(n: number, max: number, dot: boolean) {
  if (dot) return "";
  return n > max ? max + "+" : String(n);
}

function Bell() {
  return (
    <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M6 9a6 6 0 0 1 12 0c0 6 2.5 7.5 2.5 7.5h-17S6 15 6 9Z" />
      <path d="M10 20a2.2 2.2 0 0 0 4 0" />
    </svg>
  );
}

function Inbox() {
  return (
    <svg width="100%" height="100%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3.5 13.5 6.3 5.8A1.5 1.5 0 0 1 7.7 4.8h8.6a1.5 1.5 0 0 1 1.4 1l2.8 7.7" />
      <path d="M3.5 13.5V18a1.5 1.5 0 0 0 1.5 1.5h14a1.5 1.5 0 0 0 1.5-1.5v-4.5h-5.2a3.3 3.3 0 0 1-6.6 0Z" />
    </svg>
  );
}

type Shown = { cur: number; prev: number; tick: number };

export function BadgePingCounter({
  initialCount = 0,
  count: controlled,
  onCountChange,
  max = 99,
  dot = false,
  icon = "bell",
  label = "Notifications",
  color = "#ef4444",
  size = 44,
  tone = "light",
  children,
  className,
}: BadgePingCounterProps) {
  const [inner, setInner] = useState(initialCount);
  const count = Math.max(0, Math.floor(controlled ?? inner));
  const [shown, setShown] = useState<Shown>({ cur: count, prev: count, tick: 0 });
  // Track the change in render so every new count remounts the animated layer.
  if (shown.cur !== count) setShown({ cur: count, prev: shown.cur, tick: shown.tick + 1 });

  const accent = COLOR_RE.test(color) ? color : "#ef4444";
  const up = shown.tick > 0 && shown.cur > shown.prev;
  const down = shown.tick > 0 && shown.cur < shown.prev;
  const cur = fmt(shown.cur, max, dot);
  const prev = fmt(shown.prev, max, dot);
  const roll = !dot && shown.tick > 0 && shown.prev > 0 && shown.cur > 0 && cur !== prev;
  const gone = shown.tick > 0 && shown.cur === 0;
  const present = shown.cur > 0 || shown.tick > 0;
  const k = shown.tick;
  const announce = shown.tick > 0 && shown.cur > shown.prev ? shown.cur + " new, " + shown.cur + " unread" : "";

  const press = () => {
    if (controlled === undefined) setInner(count + 1);
    onCountChange?.(count + 1);
  };

  return (
    <>
      <style>{CSS}</style>
      <button
        type="button"
        onClick={press}
        data-tone={tone}
        aria-label={label + (count > 0 ? ", " + count + " unread" : "")}
        className={
          "bpc focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[var(--bpc-c)] " + (className ?? "")
        }
        style={{ width: size, height: size, borderRadius: 9999, ["--bpc-c" as string]: accent, ["--bpc-k" as string]: Math.max(0.8, 1 + (size / 44 - 1) * 0.5) } as CSSProperties}
      >
        <span key={"i" + (up ? k : 0)} className="bpc-ico" data-s={up ? (children || icon === "inbox" ? "hop" : "swing") : undefined} style={children ? undefined : { width: Math.round(size * 0.43), height: Math.round(size * 0.43) }}>
          {children ?? (icon === "inbox" ? <Inbox /> : <Bell />)}
        </span>
        {present && (
          <span
            key={"s" + k}
            className="bpc-slot"
            aria-hidden="true"
            data-up={up && !gone ? "" : undefined}
            data-dn={down && !gone ? "" : undefined}
            data-out={gone ? "" : undefined}
          >
            <span className="bpc-pill" data-dot={dot ? "" : undefined}>
              {up && <span className="bpc-ring" data-a="" />}
              {up && <span className="bpc-ring" data-b="" />}
              {!dot && (
                <span className="bpc-num" data-roll={roll ? "" : undefined}>
                  <span>{roll ? cur : gone ? prev : cur}</span>
                  {roll && (
                    <span className="bpc-gone" data-d={down ? "" : undefined}>
                      {prev}
                    </span>
                  )}
                  {roll && (
                    <span className="bpc-in" data-d={down ? "" : undefined}>
                      {cur}
                    </span>
                  )}
                </span>
              )}
            </span>
          </span>
        )}
      </button>
      <span className="bpc-sr" role="status" aria-live="polite">
        {announce}
      </span>
    </>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

An increment should read as an event, so every new count runs one coordinated sequence. The old number slides out and the new one rolls in from below inside a clipped window (0.46s, a slight overshoot), the pill pops from 0.4 to 1.28 and settles at 1 on a spring-shaped curve, two rings leave it (a filled one scaling to 2.6x over 0.8s and a thin outline to 3.4x over 1s), and the bell swings from its hinge through a damped 17-degree wobble. A decrease rolls the other way with a small dip instead of a pop and no ring. Reaching zero shrinks the badge away. Counts above max (99 by default) show as 99+, and the pill widens to fit. Dot mode swaps the number for a plain dot that still pops and pings. The button paints a hairline border and soft fill from currentColor, so it sits on a white page or a black one; tone="dark" gives it a pale-on-black glass look. Pass children to put the badge on an avatar or any other mark, size to scale the whole thing, and color for the badge hue. It counts its own clicks by default; pass count and onCountChange to drive it from your data. Everything is driven by CSS animations keyed to each change, so there is no animation loop to manage and rapid bursts simply restart the sequence.

Curator’s note

Badge Ping Counter, rebuilt in October. The first version needed framer-motion and lucide; this one is plain CSS keyframes and an inline SVG, so React is the only dependency.

Pairs well with

Pro