Web InnoventixFreeCode

Count Badge

Original · free

notification count badges

byWeb InnoventixReact + Tailwind
badgecountbadges
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/badge-count.json
badge-count.tsx
"use client";

import { useCallback, useId, useState, type SVGProps } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type ChannelId = "messages" | "mentions" | "cart" | "downloads";

interface Channel {
  id: ChannelId;
  label: string;
  item: string;
  hint: string;
  count: number;
}

const INITIAL_CHANNELS: Channel[] = [
  { id: "messages", label: "Messages", item: "message", hint: "Direct replies", count: 3 },
  { id: "mentions", label: "Mentions", item: "mention", hint: "You were tagged", count: 12 },
  { id: "cart", label: "Cart", item: "item", hint: "Awaiting checkout", count: 0 },
  { id: "downloads", label: "Downloads", item: "download", hint: "Ready to install", count: 128 },
];

function unreadText(n: number): string {
  if (n === 0) return "no unread notifications";
  return `${n} unread notification${n === 1 ? "" : "s"}`;
}

function ChannelGlyph({ id, className }: { id: ChannelId; className?: string }) {
  const shared: SVGProps<SVGSVGElement> = {
    viewBox: "0 0 24 24",
    fill: "none",
    stroke: "currentColor",
    strokeWidth: 1.75,
    strokeLinecap: "round",
    strokeLinejoin: "round",
    "aria-hidden": true,
    className,
  };
  switch (id) {
    case "messages":
      return (
        <svg {...shared}>
          <path d="M21 11.5a8.4 8.4 0 0 1-8.5 8.5 8.5 8.5 0 0 1-3.8-.9L3 21l1.9-5.7A8.4 8.4 0 0 1 4 11.5 8.5 8.5 0 0 1 12.5 3 8.4 8.4 0 0 1 21 11.5Z" />
        </svg>
      );
    case "mentions":
      return (
        <svg {...shared}>
          <circle cx="12" cy="12" r="4" />
          <path d="M16 8v5a3 3 0 0 0 6 0v-1a10 10 0 1 0-3.9 7.9" />
        </svg>
      );
    case "cart":
      return (
        <svg {...shared}>
          <circle cx="9" cy="20" r="1.4" />
          <circle cx="18" cy="20" r="1.4" />
          <path d="M2 3h3l2.5 12.4a1.8 1.8 0 0 0 1.8 1.4h8.4a1.8 1.8 0 0 0 1.8-1.4L22 7H6.2" />
        </svg>
      );
    case "downloads":
      return (
        <svg {...shared}>
          <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
          <path d="M7 10l5 5 5-5" />
          <path d="M12 15V3" />
        </svg>
      );
  }
}

function IconBell({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.75}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M6 8a6 6 0 1 1 12 0c0 7 3 8 3 8H3s3-1 3-8" />
      <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
    </svg>
  );
}

function IconPlus({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M12 5v14M5 12h14" />
    </svg>
  );
}

function IconCheck({ className }: { className?: string }) {
  return (
    <svg
      viewBox="0 0 24 24"
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      aria-hidden="true"
      className={className}
    >
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

function CountBadge({
  count,
  cap,
  dot,
  reduced,
}: {
  count: number;
  cap: number;
  dot: boolean;
  reduced: boolean;
}) {
  const label = count > cap ? `${cap}+` : String(count);

  return (
    <AnimatePresence initial={false}>
      {count > 0 && (
        <motion.span
          key={dot ? "dot" : "count"}
          initial={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.4 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}
          transition={reduced ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 22 }}
          aria-hidden="true"
          className={
            dot
              ? "absolute right-0 top-0 h-3 w-3 rounded-full bg-rose-500 shadow-sm ring-2 ring-white dark:ring-slate-900"
              : "absolute -right-1.5 -top-1.5 flex h-5 min-w-[1.25rem] items-center justify-center overflow-hidden rounded-full bg-rose-500 px-1.5 text-[0.6875rem] font-bold leading-none tabular-nums text-white shadow-sm ring-2 ring-white dark:ring-slate-900"
          }
        >
          {!dot &&
            (reduced ? (
              <span>{label}</span>
            ) : (
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span
                  key={label}
                  initial={{ y: -9, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  exit={{ y: 9, opacity: 0 }}
                  transition={{ duration: 0.18, ease: "easeOut" }}
                  className="block"
                >
                  {label}
                </motion.span>
              </AnimatePresence>
            ))}
        </motion.span>
      )}
    </AnimatePresence>
  );
}

export default function BadgeCount() {
  const uid = useId();
  const prefersReduced = useReducedMotion();
  const reduced = prefersReduced ?? false;

  const [channels, setChannels] = useState<Channel[]>(INITIAL_CHANNELS);
  const [cap, setCap] = useState<number>(99);
  const [dotMode, setDotMode] = useState<boolean>(false);
  const [message, setMessage] = useState<string>("");
  const [feed, setFeed] = useState<string[]>([]);

  const total = channels.reduce((sum, c) => sum + c.count, 0);

  const announce = useCallback((text: string) => {
    setMessage(text);
    setFeed((prev) => [text, ...prev].slice(0, 5));
  }, []);

  const addTo = useCallback(
    (id: ChannelId) => {
      const ch = channels.find((c) => c.id === id);
      if (!ch) return;
      const nextCount = ch.count + 1;
      setChannels((prev) => prev.map((c) => (c.id === id ? { ...c, count: nextCount } : c)));
      announce(`New ${ch.item} in ${ch.label} — ${nextCount} unread.`);
    },
    [channels, announce],
  );

  const readChannel = useCallback(
    (id: ChannelId) => {
      const ch = channels.find((c) => c.id === id);
      if (!ch || ch.count === 0) return;
      setChannels((prev) => prev.map((c) => (c.id === id ? { ...c, count: 0 } : c)));
      announce(`Opened ${ch.label}. Marked as read.`);
    },
    [channels, announce],
  );

  const markAll = useCallback(() => {
    if (total === 0) return;
    setChannels((prev) => prev.map((c) => ({ ...c, count: 0 })));
    announce("All channels marked as read.");
  }, [total, announce]);

  const capChoices = [9, 99, 999];

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-6 py-16 text-slate-900 sm:py-20 dark:bg-slate-950 dark:text-slate-100">
      <style>{`
        @keyframes bc-ring {
          0% { transform: scale(0.85); opacity: 0.55; }
          70% { transform: scale(2); opacity: 0; }
          100% { transform: scale(2); opacity: 0; }
        }
        @keyframes bc-live {
          0%, 100% { opacity: 1; }
          50% { opacity: 0.35; }
        }
        @media (prefers-reduced-motion: reduce) {
          [data-bc-anim] { animation: none !important; }
        }
      `}</style>

      <div
        aria-hidden="true"
        className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/15"
      />
      <div
        aria-hidden="true"
        className="pointer-events-none absolute -bottom-28 -left-20 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-500/10"
      />

      <div className="relative mx-auto max-w-5xl">
        <header className="mb-10 max-w-2xl">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white px-3 py-1 text-xs font-medium tracking-wide text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300">
            <span className="relative flex h-2 w-2">
              <span
                data-bc-anim
                className="absolute inline-flex h-full w-full rounded-full bg-emerald-400"
                style={reduced ? undefined : { animation: "bc-live 1.8s ease-in-out infinite" }}
              />
              <span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500" />
            </span>
            Live notification counters
          </span>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-4xl">
            Count badges that stay in sync
          </h2>
          <p className="mt-3 text-base leading-relaxed text-slate-600 dark:text-slate-400">
            Animated, overflow-aware unread counters with dot and numeric variants. Simulate incoming
            events, open a channel to clear it, and every change is announced to assistive tech.
          </p>
        </header>

        <div className="grid gap-6 lg:grid-cols-5">
          {/* Notification tray */}
          <div className="lg:col-span-3">
            <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900">
              <div className="flex items-center justify-between gap-4">
                <div className="flex items-center gap-3">
                  <button
                    type="button"
                    onClick={markAll}
                    aria-label={`Notifications: ${unreadText(total)}. Activate to mark all as read.`}
                    className="relative inline-flex h-12 w-12 items-center justify-center rounded-2xl border border-slate-200 bg-slate-50 text-slate-700 transition hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
                  >
                    {!reduced && total > 0 && (
                      <span
                        data-bc-anim
                        aria-hidden="true"
                        className="pointer-events-none absolute -right-1.5 -top-1.5 h-5 w-5 rounded-full bg-rose-400/70"
                        style={{ animation: "bc-ring 2s ease-out infinite" }}
                      />
                    )}
                    <IconBell className="relative h-5 w-5" />
                    <CountBadge count={total} cap={cap} dot={dotMode} reduced={reduced} />
                  </button>
                  <div>
                    <p className="text-sm font-semibold">Notification tray</p>
                    <p className="text-xs text-slate-500 dark:text-slate-400" aria-hidden="true">
                      {total === 0 ? "You're all caught up" : `${total} unread across channels`}
                    </p>
                  </div>
                </div>

                <button
                  type="button"
                  onClick={markAll}
                  disabled={total === 0}
                  className="inline-flex items-center gap-1.5 rounded-full border border-slate-200 px-3 py-1.5 text-xs font-medium text-slate-600 transition hover:border-slate-300 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:opacity-40 dark:border-slate-700 dark:text-slate-300 dark:hover:text-white dark:focus-visible:ring-offset-slate-900"
                >
                  <IconCheck className="h-3.5 w-3.5" />
                  Mark all read
                </button>
              </div>

              <ul className="mt-7 grid grid-cols-2 gap-3 sm:grid-cols-4">
                {channels.map((ch) => (
                  <li key={ch.id}>
                    <button
                      type="button"
                      onClick={() => readChannel(ch.id)}
                      aria-label={`${ch.label}: ${unreadText(ch.count)}. Activate to mark as read.`}
                      className="group flex w-full flex-col items-center gap-2 rounded-2xl border border-slate-200 bg-slate-50/60 px-3 py-4 transition hover:border-indigo-300 hover:bg-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-800 dark:bg-slate-800/40 dark:hover:border-indigo-500/60 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
                    >
                      <span className="relative inline-flex h-11 w-11 items-center justify-center rounded-xl bg-white text-slate-600 ring-1 ring-slate-200 transition group-hover:text-indigo-600 dark:bg-slate-900 dark:text-slate-300 dark:ring-slate-700 dark:group-hover:text-indigo-400">
                        <ChannelGlyph id={ch.id} className="h-5 w-5" />
                        <CountBadge count={ch.count} cap={cap} dot={dotMode} reduced={reduced} />
                      </span>
                      <span className="text-center">
                        <span className="block text-sm font-medium text-slate-800 dark:text-slate-100">
                          {ch.label}
                        </span>
                        <span className="mt-0.5 block text-[0.6875rem] text-slate-500 dark:text-slate-400">
                          {ch.hint}
                        </span>
                      </span>
                    </button>
                  </li>
                ))}
              </ul>

              <div className="mt-7 border-t border-slate-200 pt-5 dark:border-slate-800">
                <p className="text-xs font-medium uppercase tracking-wide text-slate-500 dark:text-slate-400">
                  Simulate incoming events
                </p>
                <div className="mt-3 flex flex-wrap gap-2">
                  {channels.map((ch) => (
                    <button
                      key={ch.id}
                      type="button"
                      onClick={() => addTo(ch.id)}
                      className="inline-flex items-center gap-1.5 rounded-full bg-slate-900 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-slate-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
                    >
                      <IconPlus className="h-3.5 w-3.5" />
                      Add {ch.item}
                    </button>
                  ))}
                </div>
              </div>
            </div>
          </div>

          {/* Settings + activity */}
          <div className="space-y-6 lg:col-span-2">
            <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <h3 className="text-sm font-semibold">Badge settings</h3>

              <div className="mt-4 flex items-center justify-between gap-4">
                <span id={`${uid}-dot-label`} className="text-sm text-slate-700 dark:text-slate-300">
                  Compact dot badges
                  <span className="mt-0.5 block text-xs text-slate-500 dark:text-slate-400">
                    Hide the number, show presence only
                  </span>
                </span>
                <button
                  type="button"
                  role="switch"
                  aria-checked={dotMode}
                  aria-labelledby={`${uid}-dot-label`}
                  onClick={() => setDotMode((v) => !v)}
                  className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                    dotMode ? "bg-indigo-500" : "bg-slate-300 dark:bg-slate-700"
                  }`}
                >
                  <span
                    className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition ${
                      dotMode ? "translate-x-5" : "translate-x-0.5"
                    }`}
                  />
                </button>
              </div>

              <fieldset className="mt-6">
                <legend className="text-sm text-slate-700 dark:text-slate-300">Overflow cap</legend>
                <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                  Counts above the cap collapse to “{cap}+”.
                </p>
                <div className="mt-3 inline-flex rounded-xl border border-slate-200 bg-slate-50 p-1 dark:border-slate-700 dark:bg-slate-800">
                  {capChoices.map((n) => (
                    <label
                      key={n}
                      className="relative cursor-pointer select-none"
                    >
                      <input
                        type="radio"
                        name={`${uid}-cap`}
                        value={n}
                        checked={cap === n}
                        onChange={() => setCap(n)}
                        className="peer sr-only"
                      />
                      <span className="block rounded-lg px-3.5 py-1.5 text-sm font-medium text-slate-600 transition peer-checked:bg-white peer-checked:text-slate-900 peer-checked:shadow-sm peer-focus-visible:ring-2 peer-focus-visible:ring-indigo-500 dark:text-slate-300 dark:peer-checked:bg-slate-950 dark:peer-checked:text-white">
                        {n}+
                      </span>
                    </label>
                  ))}
                </div>
              </fieldset>
            </div>

            <div className="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
              <h3 className="text-sm font-semibold">Recent activity</h3>
              {feed.length === 0 ? (
                <p className="mt-3 text-sm text-slate-500 dark:text-slate-400">
                  Add or clear a notification to see events logged here.
                </p>
              ) : (
                <ul className="mt-3 space-y-2">
                  {feed.map((entry, i) => (
                    <li
                      key={`${entry}-${i}`}
                      className="flex items-start gap-2 text-sm text-slate-600 dark:text-slate-300"
                    >
                      <span className="mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full bg-indigo-500" aria-hidden="true" />
                      <span>{entry}</span>
                    </li>
                  ))}
                </ul>
              )}
            </div>
          </div>
        </div>

        {/* Static variant showcase */}
        <div className="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8 dark:border-slate-800 dark:bg-slate-900">
          <h3 className="text-sm font-semibold">Badge patterns</h3>
          <div className="mt-5 flex flex-wrap items-center gap-x-8 gap-y-6">
            <span className="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
              Inbox
              <span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-rose-500 px-1.5 text-[0.6875rem] font-bold tabular-nums text-white">
                4
              </span>
            </span>

            <button
              type="button"
              className="inline-flex items-center gap-2 rounded-full border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 transition hover:border-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:focus-visible:ring-offset-slate-900"
            >
              Updates
              <span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-slate-900 px-1.5 text-[0.6875rem] font-bold tabular-nums text-white dark:bg-white dark:text-slate-900">
                8
              </span>
            </button>

            <span className="inline-flex items-center gap-2 rounded-full bg-indigo-50 px-3 py-1 text-sm font-medium text-indigo-700 dark:bg-indigo-500/15 dark:text-indigo-300">
              <span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-indigo-600 px-1.5 text-[0.6875rem] font-bold tabular-nums text-white">
                99+
              </span>
              new updates
            </span>

            <span className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-sm font-medium text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300">
              <span className="relative flex h-2.5 w-2.5">
                <span
                  data-bc-anim
                  className="absolute inline-flex h-full w-full rounded-full bg-emerald-400"
                  style={reduced ? undefined : { animation: "bc-live 1.6s ease-in-out infinite" }}
                />
                <span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500" />
              </span>
              Live
            </span>

            <span className="inline-flex items-center gap-2 text-sm text-slate-700 dark:text-slate-300">
              <span className="relative inline-flex h-9 w-9 items-center justify-center rounded-xl bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-300">
                <ChannelGlyph id="messages" className="h-4 w-4" />
                <span className="absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full bg-rose-500 ring-2 ring-white dark:ring-slate-900" />
              </span>
              Presence dot
            </span>

            <span className="inline-flex items-center gap-2 rounded-full bg-amber-50 px-3 py-1 text-sm font-medium text-amber-700 dark:bg-amber-500/15 dark:text-amber-300">
              <span className="inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-amber-500 px-1.5 text-[0.6875rem] font-bold tabular-nums text-white">
                3
              </span>
              needs review
            </span>
          </div>
        </div>
      </div>

      <p className="sr-only" role="status" aria-live="polite">
        {message}
      </p>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →