A status badge with semantic variants, an optional leading dot, tabular-nums counts, and a blur-crossfade when the variant changes.
npx shadcn@latest add @paragon/badge"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 };