Data Display

Badge

A status badge with semantic variants, an optional leading dot, tabular-nums counts, and a blur-crossfade when the variant changes.

Install

npx shadcn@latest add @paragon/badge

badge.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium whitespace-nowrap tabular-nums transition-[background-color,color,border-color] duration-(--duration-quick) ease-(--ease-out) [&_svg]:size-3 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default: "bg-secondary text-secondary-foreground",
        success:
          "bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-400",
        warning:
          "bg-amber-500/10 text-amber-700 dark:bg-amber-500/15 dark:text-amber-400",
        destructive:
          "bg-destructive/10 text-destructive dark:bg-destructive/15 dark:text-red-400",
        outline: "border border-border text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  },
);

export interface BadgeProps
  extends React.ComponentProps<"span">,
    VariantProps<typeof badgeVariants> {
  /** Leading presence dot in the variant's color. */
  dot?: boolean;
  /** Renders content changes instantly, no crossfade. */
  static?: boolean;
}

/**
 * A status badge. Counts read in tabular figures so widths hold steady.
 * When the `variant` prop changes (status transitions), the surface color
 * transitions while the content blur-crossfades — 2px of blur masks the
 * imperfect swap. Under reduced motion the crossfade drops to a plain swap.
 */
export function Badge({
  variant,
  dot = false,
  static: isStatic = false,
  className,
  children,
  ...props
}: BadgeProps) {
  const reduced = useReducedMotion();
  const content = (
    <>
      {dot && (
        <span
          aria-hidden
          className="pointer-events-none mr-1.5 size-1.5 shrink-0 rounded-full bg-current"
        />
      )}
      {children}
    </>
  );

  return (
    <span
      data-slot="badge"
      className={cn(badgeVariants({ variant }), className)}
      {...props}
    >
      {isStatic || reduced ? (
        <span className="inline-flex items-center">{content}</span>
      ) : (
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span
            key={variant ?? "default"}
            className="inline-flex items-center"
            initial={{ opacity: 0, filter: "blur(2px)" }}
            animate={{ opacity: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, filter: "blur(2px)" }}
            transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
          >
            {content}
          </motion.span>
        </AnimatePresence>
      )}
    </span>
  );
}

export { badgeVariants };