niceui

Animated Badge

Status badges in five tones with pulsing live dots, a rolling counter and a shimmering “New” tag.

ReactTailwind CSSTypeScriptComponentsMIT · Free
Live preview

Installation

01

Install with the shadcn CLI. The NiceUI theme comes along the first time.

$ npx shadcn@latest add https://niceui.dev/r/components-animated-badge.json
02

Or copy the source below. Dependencies: lucide-react.

Source

components/niceui/ui/core/Badge.tsx
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { Bell, Minus, Plus, Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';

export type BadgeTone = 'neutral' | 'brand' | 'success' | 'warning' | 'error';

const TONES: Record<BadgeTone, { badge: string; dot: string }> = {
  neutral: { badge: 'bg-white/5 text-ink-200 ring-white/10', dot: 'bg-ink-400' },
  brand: { badge: 'bg-brand-500/10 text-brand-300 ring-brand-500/25', dot: 'bg-brand-400' },
  success: { badge: 'bg-emerald-500/10 text-emerald-300 ring-emerald-500/25', dot: 'bg-emerald-400' },
  warning: { badge: 'bg-amber-500/10 text-amber-300 ring-amber-500/25', dot: 'bg-amber-400' },
  error: { badge: 'bg-rose-500/10 text-rose-300 ring-rose-500/25', dot: 'bg-rose-400' },
};

export type BadgeProps = {
  children: ReactNode;
  tone?: BadgeTone;
  dot?: boolean;
  pulse?: boolean;
  className?: string;
};

export function Badge({ children, tone = 'neutral', dot = false, pulse = false, className }: BadgeProps) {
  const t = TONES[tone];
  return (
    <span
      className={cn(
        'inline-flex h-6 items-center gap-1.5 whitespace-nowrap rounded-full px-2.5 text-xs font-medium ring-1 ring-inset',
        t.badge,
        className,
      )}
    >
      {(dot || pulse) && (
        <span className="relative flex h-1.5 w-1.5">
          {pulse && <span className={cn('absolute inset-0 animate-pulse-ring rounded-full', t.dot)} />}
          <span className={cn('relative h-1.5 w-1.5 rounded-full', t.dot)} />
        </span>
      )}
      {children}
    </span>
  );
}

function once(el: HTMLElement | null) {
  if (!el || el.dataset.played) return null;
  el.dataset.played = '1';
  return el;
}

/** Numeric badge that rolls the digits vertically when the count changes. */
export function CountBadge({ count, max = 99, className }: { count: number; max?: number; className?: string }) {
  const prev = useRef(count);
  const [anim, setAnim] = useState<{ from: number; dir: 1 | -1; key: number } | null>(null);

  useLayoutEffect(() => {
    if (prev.current === count) return;
    setAnim({ from: prev.current, dir: count > prev.current ? 1 : -1, key: Date.now() });
    prev.current = count;
    const t = window.setTimeout(() => setAnim(null), 400);
    return () => window.clearTimeout(t);
  }, [count]);

  const fmt = (n: number) => (n > max ? `${max}+` : String(n));
  if (count <= 0) return null;

  return (
    <span
      className={cn(
        'relative inline-flex h-5 min-w-[20px] animate-scale-in items-center justify-center overflow-hidden rounded-full bg-rose-500 px-1.5 text-[11px] font-semibold tabular-nums text-white shadow-[0_0_0_2px_#131316]',
        className,
      )}
    >
      {anim && (
        <span
          key={`o${anim.key}`}
          className="absolute"
          ref={(el) =>
            once(el)?.animate([{ transform: 'translateY(0)', opacity: 1 }, { transform: `translateY(${-anim.dir * 100}%)`, opacity: 0 }], {
              duration: 320,
              easing: 'cubic-bezier(0.16,1,0.3,1)',
              fill: 'forwards',
            })
          }
        >
          {fmt(anim.from)}
        </span>
      )}
      <span
        key={`n${anim?.key ?? 0}`}
        ref={(el) =>
          anim &&
          once(el)?.animate([{ transform: `translateY(${anim.dir * 100}%)`, opacity: 0 }, { transform: 'translateY(0)', opacity: 1 }], {
            duration: 380,
            easing: 'cubic-bezier(0.34,1.56,0.64,1)',
          })
        }
      >
        {fmt(count)}
      </span>
    </span>
  );
}

export function ShimmerBadge({ children = 'New', className }: { children?: ReactNode; className?: string }) {
  return (
    <span
      className={cn(
        'relative inline-flex h-6 items-center gap-1 overflow-hidden rounded-full bg-brand-500/10 px-2.5 text-xs font-medium text-brand-200 ring-1 ring-inset ring-brand-400/30',
        className,
      )}
    >
      <Sparkles className="h-3 w-3" />
      {children}
      <span
        aria-hidden
        className="absolute inset-0 animate-shimmer bg-[linear-gradient(110deg,transparent_35%,rgba(255,255,255,0.22)_50%,transparent_65%)] bg-[length:200%_100%]"
      />
    </span>
  );
}

export function AnimatedBadgeDemo() {
  const [count, setCount] = useState(3);
  const tones: BadgeTone[] = ['neutral', 'brand', 'success', 'warning', 'error'];
  const labels = ['Draft', 'In review', 'Deployed', 'Degraded', 'Failed'];

  return (
    <div className="mx-auto flex w-full max-w-lg flex-col items-center gap-8">
      <div className="flex flex-wrap justify-center gap-2">
        {tones.map((tone, i) => (
          <span key={tone} className="animate-fade-up" style={{ animationDelay: `${i * 50}ms` }}>
            <Badge tone={tone} dot>
              {labels[i]}
            </Badge>
          </span>
        ))}
      </div>
      <div className="flex flex-wrap items-center justify-center gap-2">
        <Badge tone="success" pulse>
          All systems operational
        </Badge>
        <Badge tone="error" pulse>
          Live
        </Badge>
        <ShimmerBadge />
      </div>
      <div className="flex items-center gap-4 rounded-2xl bg-ink-900 p-3 pr-4 shadow-card">
        <div className="relative flex h-10 w-10 items-center justify-center rounded-xl bg-ink-850 text-ink-200 shadow-hairline">
          <Bell className="h-4 w-4" />
          <CountBadge count={count} className="absolute -right-1.5 -top-1.5" />
        </div>
        <div className="text-sm">
          <p className="font-medium text-ink-100">Notifications</p>
          <p className="text-xs text-ink-400">{count === 0 ? 'You’re all caught up' : `${count} unread`}</p>
        </div>
        <div className="ml-4 flex gap-1">
          {[
            { icon: Minus, fn: () => setCount((c) => Math.max(0, c - 1)), label: 'Decrease' },
            { icon: Plus, fn: () => setCount((c) => c + 1), label: 'Increase' },
          ].map(({ icon: Icon, fn, label }) => (
            <button
              key={label}
              onClick={fn}
              aria-label={label}
              className="focus-ring flex h-8 w-8 items-center justify-center rounded-lg text-ink-300 transition hover:bg-white/5 hover:text-ink-50 active:scale-90"
            >
              <Icon className="h-4 w-4" />
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}