Badge
Badges are small, theme-aware status descriptors for dashboards, CMS-style tools, and any UI that needs to label state (Draft/Published, CREATE/UPDATE/DELETE, Active/Disabled, role tags, severity labels) alongside a dedicated notification-counter variant for floating counts on icons and buttons.
- Preview
- Code
StatusFeaturedDismissible
Notifications3
import { useState } from 'react';
import { Badge } from '@ignix-ui/badge';
import { Mail, Star } from 'lucide-react';
function BadgeExample() {
const [removed, setRemoved] = useState(false);
return (
<div className="flex flex-wrap items-center gap-4">
<Badge variant="default" size="md">
Status
</Badge>
<Badge variant="default" size="md" icon={<Star className="h-3 w-3" />}>
Featured
</Badge>
{!removed && (
<Badge variant="default" size="md" onRemove={() => setRemoved(true)}>
Dismissible
</Badge>
)}
<Badge variant="notification" anchor={<Mail className="h-8 w-8" />}>
3
</Badge>
</div>
);
}
Installation
- CLI
- Manual
ignix add component badge
import React from "react";
import { motion, type Variants } from "framer-motion";
import { X } from "lucide-react";
import { cn } from "../../../utils/cn";
export type BadgeVariant =
| "default"
| "secondary"
| "success"
| "warning"
| "destructive"
| "info"
| "purple"
| "outline"
| "notification";
export type BadgeSize = "sm" | "md" | "lg";
export type BadgeAnimation = "none" | "pulse" | "bounce" | "tinypop";
export interface BadgeProps
extends Omit<
React.HTMLAttributes<HTMLSpanElement>,
"children" | "color" | "onAnimationStart" | "onAnimationEnd" | "onDrag" | "onDragStart" | "onDragEnd"
> {
children?: React.ReactNode;
/** Color/style of the badge. `"notification"` reproduces the original circular counter look. */
variant?: BadgeVariant;
size?: BadgeSize;
/** Leading icon, rendered before the content. */
icon?: React.ReactNode;
/** Renders a dismiss button after the content; called when it's clicked. */
onRemove?: () => void;
/** Accessible label for the dismiss button. Default `"Remove"`. */
removeLabel?: string;
/**
* Anchors the badge to the top-right corner of this element instead of rendering inline -
* reproduces the original `mode="attached"` notification-badge positioning.
*/
anchor?: React.ReactNode;
/** Motion applied to the badge. Defaults to `"tinypop"` for `variant="notification"`, `"none"` otherwise. */
animate?: BadgeAnimation;
className?: string;
}
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
success: "bg-success text-success-foreground",
warning: "bg-warning text-warning-foreground",
destructive: "bg-destructive text-destructive-foreground",
info: "bg-info text-info-foreground",
purple: "bg-purple text-purple-foreground",
outline: "bg-transparent text-foreground border border-border",
notification: "bg-primary text-primary-foreground shadow-lg shadow-primary/25 ring-2 ring-primary/20",
};
const SIZE_CLASSES: Record<BadgeSize, string> = {
sm: "text-[11px] px-2 py-0.5 gap-1",
md: "text-xs px-2.5 py-0.5 gap-1.5",
lg: "text-sm px-3 py-1 gap-1.5",
};
// The notification variant is a circular counter, not a text pill, so it gets its own
// size scale (fixed height/min-width) instead of SIZE_CLASSES' padding-based one.
const NOTIFICATION_SIZE_CLASSES: Record<BadgeSize, string> = {
sm: "h-4 min-w-[16px] px-1 text-[10px]",
md: "h-5 min-w-[20px] px-1.5 text-xs",
lg: "h-6 min-w-[24px] px-2 text-sm",
};
const ANIMATION_VARIANTS: Record<Exclude<BadgeAnimation, "none">, Variants> = {
pulse: {
initial: { scale: 1 },
animate: { scale: [1, 1.1, 1], transition: { duration: 2, repeat: Infinity, ease: "easeOut" } },
},
bounce: {
initial: { y: 0, scale: 1 },
animate: {
y: [0, -6, 0],
scale: [1, 1.15, 1],
transition: { duration: 0.8, repeat: Infinity, ease: "easeOut" },
},
},
tinypop: {
initial: { scale: 1 },
animate: { scale: [1, 1.25, 1], transition: { duration: 1.5, repeat: Infinity, ease: "easeInOut" } },
},
};
const NONE_VARIANT: Variants = { initial: {}, animate: {} };
/**
* Badge is a versatile, theme-aware status indicator - color pill, outline tag, or (via
* `variant="notification"`) a floating counter attached to an icon or button.
*/
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(({
children,
variant = "default",
size = "md",
icon,
onRemove,
removeLabel = "Remove",
anchor,
animate,
className,
...props
}, ref) => {
const isNotification = variant === "notification";
const resolvedAnimation = animate ?? (isNotification ? "tinypop" : "none");
const motionVariants = resolvedAnimation === "none" ? NONE_VARIANT : ANIMATION_VARIANTS[resolvedAnimation];
const content = (
<motion.span
ref={ref}
variants={motionVariants}
initial="initial"
animate="animate"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className={cn(
"relative inline-flex items-center justify-center rounded-full font-medium leading-none whitespace-nowrap",
VARIANT_CLASSES[variant],
isNotification ? NOTIFICATION_SIZE_CLASSES[size] : SIZE_CLASSES[size],
anchor && "absolute -top-1 -right-1 sm:-top-2 sm:-right-2",
className
)}
{...props}
>
{icon && (
<span className="shrink-0" aria-hidden="true">
{icon}
</span>
)}
{children}
{onRemove && (
<button
type="button"
onClick={(event) => {
event.stopPropagation();
onRemove();
}}
aria-label={removeLabel}
className="-mr-0.5 ml-0.5 shrink-0 rounded-full p-0.5 hover:bg-black/10 focus:outline-none focus-visible:ring-1 focus-visible:ring-current dark:hover:bg-white/10"
>
<X className="h-3 w-3" />
</button>
)}
{isNotification && (
<span
className="pointer-events-none absolute inset-0 rounded-full bg-gradient-to-t from-transparent to-white/20 dark:to-white/10"
aria-hidden="true"
/>
)}
</motion.span>
);
if (anchor) {
return (
<span className="relative inline-flex items-center">
{anchor}
{content}
</span>
);
}
return content;
});
Badge.displayName = "Badge";
export default Badge;
Usage
Import the component:
import { Badge } from '@ignix-ui/badge';
Status Pill
<Badge variant="success">Published</Badge>
<Badge variant="secondary">Draft</Badge>
<Badge variant="destructive">Archived</Badge>
With an Icon
<Badge variant="info" icon={<Star className="h-3 w-3" />}>
Featured
</Badge>
Dismissible
import { useState } from 'react';
function DismissibleTag() {
const [visible, setVisible] = useState(true);
if (!visible) return null;
return (
<Badge variant="secondary" onRemove={() => setVisible(false)}>
React
</Badge>
);
}
Notification Counter (Attached)
<Badge variant="notification" anchor={<Mail className="h-10 w-10" />}>
3
</Badge>
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children | React.ReactNode | undefined | Content displayed inside the badge |
variant | "default" | "secondary" | "success" | "warning" | "destructive" | "info" | "purple" | "outline" | "notification" | "default" | Color/style of the badge; "notification" reproduces the original circular counter look |
size | "sm" | "md" | "lg" | "md" | Size of the badge |
icon | React.ReactNode | undefined | Leading icon, rendered before the content |
onRemove | () => void | undefined | Renders a dismiss button after the content; called when it's clicked |
removeLabel | string | "Remove" | Accessible label for the dismiss button |
anchor | React.ReactNode | undefined | Anchors the badge to the top-right corner of this element instead of rendering inline |
animate | "none" | "pulse" | "bounce" | "tinypop" | "tinypop" for notification, "none" otherwise | Motion applied to the badge |
className | string | undefined | Additional CSS classes for the badge |