Badge
A small label for statuses, categories, or counts, in soft tinted or solid fills.
Live preview and source code for Badge
"use client"
import React from "react";
import type { LucideIcon } from "lucide-react";
export type BadgeVariant = "default" | "success" | "warning" | "destructive" | "info";
export type BadgeSize = "sm" | "md" | "lg";
export interface BadgeProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "children"> {
/** Text rendered inside the badge */
label?: string;
/** Visual style of the badge */
variant?: BadgeVariant;
/** Size of the badge */
size?: BadgeSize;
/** Shows a small leading status dot */
dot?: boolean;
/** lucide-react icon component rendered before the label */
icon?: LucideIcon | null;
/** Solid fill instead of the default soft tinted background */
solid?: boolean;
/** Fully rounded pill instead of a soft rectangle */
pill?: boolean;
/** Extra classes for consumers using a utility CSS framework */
className?: string;
}
const variantStyles: Record<BadgeVariant, { soft: string; text: string; solid: string; dot: string }> = {
default: { soft: "#f4f4f5", text: "#3f3f46", solid: "#3f3f46", dot: "#71717a" },
success: { soft: "#dcfce7", text: "#15803d", solid: "#16a34a", dot: "#22c55e" },
warning: { soft: "#fef3c7", text: "#b45309", solid: "#d97706", dot: "#f59e0b" },
destructive: { soft: "#fee2e2", text: "#b91c1c", solid: "#dc2626", dot: "#ef4444" },
info: { soft: "#e0f2fe", text: "#0369a1", solid: "#0284c7", dot: "#0ea5e9" },
};
const sizeStyles: Record<BadgeSize, { padding: string; fontSize: number; gap: number; icon: number; dot: number }> = {
sm: { padding: "2px 8px", fontSize: 11, gap: 4, icon: 11, dot: 5 },
md: { padding: "3px 10px", fontSize: 12, gap: 5, icon: 13, dot: 6 },
lg: { padding: "5px 12px", fontSize: 14, gap: 6, icon: 15, dot: 7 },
};
/**
* Badge — a compact label for statuses, categories, and counts.
* Soft tinted by default, `solid` for higher contrast. Renders
* standalone with zero props.
*/
export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
(
{
label = "Badge",
variant = "default",
size = "md",
dot = false,
icon: Icon = null,
solid = false,
pill = true,
className = "",
style,
...rest
},
ref
) => {
const colors = variantStyles[variant];
const { padding, fontSize, gap, icon: iconSize, dot: dotSize } = sizeStyles[size];
return (
<span
ref={ref}
className={className}
style={{
display: "inline-flex",
alignItems: "center",
gap,
padding,
fontSize,
fontWeight: 500,
fontFamily: "inherit",
lineHeight: 1.4,
whiteSpace: "nowrap",
borderRadius: pill ? "9999px" : "6px",
background: solid ? colors.solid : colors.soft,
color: solid ? "#ffffff" : colors.text,
border: solid ? "1px solid transparent" : "1px solid rgba(0,0,0,0.04)",
...style,
}}
{...rest}
>
{dot && (
<span
aria-hidden="true"
style={{
width: dotSize,
height: dotSize,
borderRadius: "9999px",
background: solid ? "rgba(255,255,255,0.85)" : colors.dot,
flexShrink: 0,
}}
/>
)}
{Icon && <Icon size={iconSize} strokeWidth={2.25} style={{ flexShrink: 0 }} />}
{label}
</span>
);
}
);
Badge.displayName = "Badge";
export default Badge;
A small label for statuses, categories and counts: the status pill you reach
for in a table row, a card header or next to a title. Soft tinted backgrounds
by default, a solid fill when it needs to carry across a busy interface, and
five colour intents that cover most of what a status ever needs to say. It
renders a single <span> with inline styles, so it drops into any React
project without a config file.
Installation
Copy the source from the Code tab above into your project as Badge.tsx. It is
one file of TypeScript and React with inline styles, so there is no Tailwind
config, no CSS import and no theme provider to set up first.
The only import beyond React is lucide-react, and it is a type-only import
for the icon prop:
npm install lucide-reactYou need the package installed at runtime only if you actually pass an icon.
If you never do, delete the import and the icon prop and the file stands on
React alone.
Usage
Every prop is optional. The label is a prop rather than a child, so there is nothing to nest:
import Badge from "./components/Badge";
export default function Example() {
return <Badge label="Shipped" variant="success" dot />;
}When to reach for it
A row of statuses in a table, where colour has to make the state readable at a
glance while the word still does the actual work. success, warning,
destructive and info are shaped for exactly that.
Metadata beside a heading: a version number, a plan tier, a content category.
The sm size and pill shape are meant for sitting next to text without
crowding it.
A count or a short tag inside a card, where a full chip component would be more structure than the thing deserves.
When not to
If it is clickable, it is not a badge. This renders a <span>, so it has no
focus ring, no keyboard handling and no pressed state. A removable filter chip
or a tag that navigates somewhere needs a <button> or an <a>, and reaching
for Button is the better call.
If the state changes while the user is looking elsewhere, a badge alone will not tell them. Announce the change from a live region and let the badge show the result.
If you need more than a word or two, the fixed padding and nowrap will
work against you. That is a callout or an alert, not a badge.
Accessibility
The leading dot is aria-hidden, deliberately: it is decoration, and the label
is what carries the meaning. That is the important property here: colour and
the dot are both redundant signals, so a screen reader and a person who cannot
distinguish the tints both get the same information from the text.
Which means the one thing you have to get right is the label itself. Failed
works; a bare ! or a colour with no word does not.
The badge renders as ordinary inline text with no ARIA role, which is correct for a static label. If it is reporting something that updates, put the live region on the container around it rather than on the badge.
Props
Every prop the Badge component accepts, with its type and default value.
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | Badge | Text rendered inside the badge. |
variant | "default" | "success" | "warning" | "destructive" | "info" | default | Visual style of the badge. |
size | "sm" | "md" | "lg" | md | Size of the badge. |
dot | boolean | false | Shows a small leading status dot. |
icon | LucideIcon | null | null | lucide-react icon component rendered before the label. |
solid | boolean | false | Solid fill instead of the default soft tinted background. |
pill | boolean | true | Fully rounded pill instead of a soft rectangle. |
Notes
dot and icon are not mutually exclusive. Set both and you get the dot
first, then the icon, then the label, in that order.
style is spread after the component's own inline styles, so anything you pass
wins. That is the escape hatch for a colour the five variants do not cover,
and it is why the variant palette is deliberately small rather than
configurable.
Every other prop lands on the underlying <span>, so title, data-* and
event handlers all pass straight through.