Badge
A small label used to display status indicators, categories, counts, or tags alongside other content.
Use Cases
- Show issue or ticket status (open, closed, in progress)
- Display category or tag labels on blog posts and articles
- Indicate notification counts on nav items or icons
- Mark items as new, beta, or deprecated
- Show user roles or permission levels (admin, editor, viewer)
- Highlight pricing tiers or feature availability
- Display technology tags on project cards
Simplest Implementation
interface BadgeProps {
children: React.ReactNode;
}
export function Badge({ children }: BadgeProps) {
return (
<span className="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{children}
</span>
);
}A static badge that does not need "use client" since it has no interactivity. The inline-flex with items-center ensures the content is vertically centered even when mixed with icons.
Variations
Color Variants
type BadgeColor = "gray" | "red" | "yellow" | "green" | "blue" | "purple";
interface BadgeProps {
children: React.ReactNode;
color?: BadgeColor;
}
const colorClasses: Record<BadgeColor, string> = {
gray: "bg-gray-100 text-gray-800",
red: "bg-red-100 text-red-800",
yellow: "bg-yellow-100 text-yellow-800",
green: "bg-green-100 text-green-800",
blue: "bg-blue-100 text-blue-800",
purple: "bg-purple-100 text-purple-800",
};
export function Badge({ children, color = "gray" }: BadgeProps) {
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${colorClasses[color]}`}
>
{children}
</span>
);
}Maps color names to Tailwind class pairs. Using the 100 background with the 800 text shade ensures readable contrast across all variants.
With Dot Indicator
type BadgeColor = "gray" | "red" | "green" | "blue";
interface BadgeProps {
children: React.ReactNode;
color?: BadgeColor;
}
const colorClasses: Record<BadgeColor, { bg: string; dot: string }> = {
gray: { bg: "bg-gray-100 text-gray-800", dot: "bg-gray-500" },
red: { bg: "bg-red-100 text-red-800", dot: "bg-red-500" },
green: { bg: "bg-green-100 text-green-800", dot: "bg-green-500" },
blue: { bg: "bg-blue-100 text-blue-800", dot: "bg-blue-500" },
};
export function Badge({ children, color = "gray" }: BadgeProps) {
const classes = colorClasses[color];
return (
<span className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${classes.bg}`}>
<span className={`h-1.5 w-1.5 rounded-full ${classes.dot}`} />
{children}
</span>
);
}A small colored dot before the label adds a visual cue that reinforces the badge's meaning. Useful for status badges where color alone carries semantic weight (e.g., green = active, red = error).
Removable / Dismissible
"use client";
interface BadgeProps {
children: React.ReactNode;
onRemove: () => void;
}
export function Badge({ children, onRemove }: BadgeProps) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 py-0.5 pl-2.5 pr-1 text-xs font-medium text-blue-800">
{children}
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${children}`}
className="inline-flex h-4 w-4 items-center justify-center rounded-full text-blue-600 hover:bg-blue-200 hover:text-blue-800"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</span>
);
}The close button is inside the badge with asymmetric padding (pl-2.5 pr-1) to keep the layout balanced. The aria-label includes the badge text so screen readers announce "Remove TypeScript" rather than just "Remove".
Pill vs Rounded
type BadgeShape = "pill" | "rounded" | "square";
interface BadgeProps {
children: React.ReactNode;
shape?: BadgeShape;
}
const shapeClasses: Record<BadgeShape, string> = {
pill: "rounded-full",
rounded: "rounded-md",
square: "rounded-none",
};
export function Badge({ children, shape = "pill" }: BadgeProps) {
return (
<span
className={`inline-flex items-center bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 ${shapeClasses[shape]}`}
>
{children}
</span>
);
}Three shape options for different visual contexts. Pills feel organic and work well for tags, rounded rectangles suit table status labels, and square badges fit structured data-heavy layouts.
With Icon
interface BadgeProps {
children: React.ReactNode;
icon?: React.ReactNode;
}
export function Badge({ children, icon }: BadgeProps) {
return (
<span className="inline-flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
{icon && <span className="h-3.5 w-3.5 shrink-0">{icon}</span>}
{children}
</span>
);
}
// Usage
<Badge
icon={
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
}
>
Verified
</Badge>Icons are wrapped in a fixed-size container with shrink-0 to prevent them from compressing when the badge text is long. The gap-1 provides consistent spacing between icon and text.
Notification Count
interface CountBadgeProps {
count: number;
max?: number;
}
export function CountBadge({ count, max = 99 }: CountBadgeProps) {
if (count <= 0) return null;
const display = count > max ? `${max}+` : String(count);
return (
<span className="inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-red-500 px-1.5 text-[11px] font-bold text-white">
{display}
</span>
);
}A standalone count badge for notification indicators. Returns null when count is zero. The min-w-5 keeps the badge circular for single digits while px-1.5 adds horizontal padding for wider numbers.
Complex Implementation
"use client";
import { forwardRef } from "react";
type BadgeVariant = "solid" | "soft" | "outline";
type BadgeColor = "gray" | "red" | "yellow" | "green" | "blue" | "purple";
type BadgeSize = "sm" | "md" | "lg";
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
color?: BadgeColor;
size?: BadgeSize;
dot?: boolean;
icon?: React.ReactNode;
onRemove?: () => void;
className?: string;
}
const colorMap: Record<BadgeColor, Record<BadgeVariant, string>> = {
gray: {
solid: "bg-gray-600 text-white",
soft: "bg-gray-100 text-gray-800",
outline: "border border-gray-300 text-gray-700",
},
red: {
solid: "bg-red-600 text-white",
soft: "bg-red-100 text-red-800",
outline: "border border-red-300 text-red-700",
},
yellow: {
solid: "bg-yellow-500 text-white",
soft: "bg-yellow-100 text-yellow-800",
outline: "border border-yellow-300 text-yellow-700",
},
green: {
solid: "bg-green-600 text-white",
soft: "bg-green-100 text-green-800",
outline: "border border-green-300 text-green-700",
},
blue: {
solid: "bg-blue-600 text-white",
soft: "bg-blue-100 text-blue-800",
outline: "border border-blue-300 text-blue-700",
},
purple: {
solid: "bg-purple-600 text-white",
soft: "bg-purple-100 text-purple-800",
outline: "border border-purple-300 text-purple-700",
},
};
const dotColorMap: Record<BadgeColor, Record<BadgeVariant, string>> = {
gray: { solid: "bg-gray-300", soft: "bg-gray-500", outline: "bg-gray-500" },
red: { solid: "bg-red-300", soft: "bg-red-500", outline: "bg-red-500" },
yellow: { solid: "bg-yellow-300", soft: "bg-yellow-500", outline: "bg-yellow-500" },
green: { solid: "bg-green-300", soft: "bg-green-500", outline: "bg-green-500" },
blue: { solid: "bg-blue-300", soft: "bg-blue-500", outline: "bg-blue-500" },
purple: { solid: "bg-purple-300", soft: "bg-purple-500", outline: "bg-purple-500" },
};
const sizeClasses: Record<BadgeSize, { badge: string; icon: string; dot: string; close: string }> = {
sm: { badge: "px-2 py-px text-[10px] gap-1", icon: "h-3 w-3", dot: "h-1 w-1", close: "h-3 w-3" },
md: { badge: "px-2.5 py-0.5 text-xs gap-1.5", icon: "h-3.5 w-3.5", dot: "h-1.5 w-1.5", close: "h-3.5 w-3.5" },
lg: { badge: "px-3 py-1 text-sm gap-1.5", icon: "h-4 w-4", dot: "h-2 w-2", close: "h-4 w-4" },
};
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(function Badge(
{
children,
variant = "soft",
color = "gray",
size = "md",
dot = false,
icon,
onRemove,
className,
},
ref
) {
const sizes = sizeClasses[size];
const colors = colorMap[color][variant];
const dotColor = dotColorMap[color][variant];
return (
<span
ref={ref}
className={[
"inline-flex items-center rounded-full font-medium",
sizes.badge,
colors,
onRemove ? "pr-1" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
>
{dot && <span className={`shrink-0 rounded-full ${sizes.dot} ${dotColor}`} />}
{!dot && icon && <span className={`shrink-0 ${sizes.icon}`}>{icon}</span>}
{children}
{onRemove && (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${typeof children === "string" ? children : ""}`}
className={`inline-flex shrink-0 items-center justify-center rounded-full opacity-60 hover:opacity-100 ${sizes.close}`}
>
<svg className="h-full w-full" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</span>
);
});Key aspects:
- Three variant styles --
solidfor high-emphasis badges,softfor default subtle labels, andoutlinefor minimal visual weight. All three are available for every color. - Nested color maps -- a
Record<Color, Record<Variant, string>>structure makes it easy to add new colors or variants without touching conditional logic. - Proportional sizing -- dot, icon, close button, and padding all scale with the badge size so the visual balance is maintained at every size.
- Dot adapts to variant -- the dot color shifts between light (on solid backgrounds) and dark (on soft/outline backgrounds) so it remains visible against any variant.
- forwardRef -- enables attaching refs for tooltip positioning, animation libraries, or measuring badge dimensions in dynamic layouts.
- Remove button opacity pattern -- uses
opacity-60 hover:opacity-100instead of separate color classes, which works correctly across all color and variant combinations without additional mappings.
Gotchas
-
Tailwind purge removes dynamic classes -- Constructing class names dynamically like
`bg-${color}-100`does not work with Tailwind's purge. Always use complete class strings in a static map. -
Badge inside flex wrapping oddly -- Badges using
inline-flexinside a parentflexcontainer may not wrap as expected. Wrap multiple badges in a container withflex flex-wrap gap-2. -
Color alone conveying meaning -- Relying only on red = error and green = success excludes colorblind users. Pair colors with icons, dots, or descriptive text.
-
Remove button click area too small -- A 12px close button is hard to tap on mobile. Ensure the close button is at least 24px on touch devices, or add invisible padding with
before:pseudo-element. -
Badge text overflow -- Very long text inside a badge breaks the layout. Use
max-w-[200px] truncateif badge content can vary in length. -
Notification count flickering -- Updating a count badge rapidly (e.g., from a WebSocket) causes visual flicker. Debounce the count or use
startTransitionto batch updates.