Badge
A label for the metadata a reader scans rather than reads. Eight hues with a low-contrast pair each, plus inverted and the house gradient, in three sizes with room for an icon.
A label for the metadata a reader scans rather than reads: status, plan tier, environment. One or two words in Title Case, and the hue does the sorting before the words are read at all.
Install
bunx shadcn@latest add @vernostudio/badgeOr, if components.json has no registry namespace to resolve @vernostudio
against, point the CLI straight at the file:
bunx shadcn@latest add https://verno-studio.vercel.app/r/badge.jsonContrast
A badge earns attention it did not pay for: color on a page of text always
wins. contrast is how that attention gets spent deliberately, and it takes
one value, "low", because only the quiet direction needs a name. Written
plain, the label sits on a solid fill and belongs to the one badge in the row
that has to be seen first. Add contrast="low" and the same hue drops to its
900 step on a 200 tint, which is what a table of twenty rows can carry
without turning into a fruit stand.
<Badge variant="green">Ready</Badge>
<Badge variant="green" contrast="low">
Ready
</Badge>A workable rule: one high-contrast badge per view, low everywhere else. And the hue carries the category, never the whole message. "Error" in red reads as an error; a bare red dot does not, so the words stay.
Inverted and verno
Two variants sit outside the hue pairs and come in one strength, so
contrast leaves them alone. Inverted is the loudest thing this component
can say: it swaps the page's own foreground and background, so it stays the
exact negative of any theme it lands in. Verno is the house mark, a dusk
gradient that runs from evening blue through deep navy into burnt orange,
the last light of the day compressed into a pill. It is a signature, so it
holds still in both modes.
Sizes
The three heights land on the 4px grid: 20, 24 and 32. Type steps with them,
down to an 11px floor for sm; smaller than that, a medium weight starts to
smudge. Digits are tabular, so a count that ticks does not make the badge
wobble.
Reach for sm inside table rows and lg only where the badge is the point
of the surface, like a plan card. md is the default for everything between.
Icons
icon draws before the label and the badge does the sizing: 12, 14 and 16px
across the three heights, so an icon drawn on a 16 grid drops in at any size.
The slot is decorative and hidden from assistive tech, which is exactly why
it cannot replace the label, only sharpen it.
<Badge icon={<Check />} variant="green" contrast="low">
Checks Passed
</Badge>Tokens
Three steps per hue: the high-contrast fill, the low-contrast tint and the text that sits on it. They ship with the component rather than with the theme, so installing the badge does not quietly widen your palette past the one step per hue the theme publishes.
The tint pairs quote the color scale. The fills mostly do not: a scale tunes its solid step for surfaces with nothing on them, and a white label on five of the hues measured under WCAG AA there. Those fills are solved instead, each the lightest value at its hue that still holds the label at 4.5:1, and a test measures every pair so the numbers cannot quietly drift back.
- --badge-amber
- oklch(81.87% 0.1969 76.46)
- --badge-amber-subtle
- oklch(96.81% 0.0495 90.24)
- --badge-amber-subtle-foreground
- oklch(52.79% 0.1496 54.65)
- --badge-blue
- oklch(57.61% 0.2508 258.23)
- --badge-blue-subtle
- oklch(96.29% 0.0195 250.59)
- --badge-blue-subtle-foreground
- oklch(53.18% 0.2399 256.99)
- --badge-gray
- oklch(0.562 0 0)
- --badge-gray-subtle
- oklch(0.94 0 0)
- --badge-gray-subtle-foreground
- oklch(0.205 0 0)
- --badge-green
- oklch(54.59% 0.1616 147.27)
- --badge-green-subtle
- oklch(96.92% 0.037 147.15)
- --badge-green-subtle-foreground
- oklch(51.75% 0.1453 147.65)
- --badge-pink
- oklch(59.41% 0.238 1.01)
- --badge-pink-subtle
- oklch(95.71% 0.0321 353.14)
- --badge-pink-subtle-foreground
- oklch(53.5% 0.2058 2.84)
- --badge-purple
- oklch(55.5% 0.3008 306.12)
- --badge-purple-subtle
- oklch(96.73% 0.0228 309.8)
- --badge-purple-subtle-foreground
- oklch(47.18% 0.2579 304)
- --badge-red
- oklch(59.23% 0.2394 23.03)
- --badge-red-subtle
- oklch(95.41% 0.0299 14.25)
- --badge-red-subtle-foreground
- oklch(54.99% 0.232 25.29)
- --badge-teal
- oklch(55.05% 0.0982 181.95)
- --badge-teal-subtle
- oklch(97.06% 0.0347 180.66)
- --badge-teal-subtle-foreground
- oklch(52.08% 0.1251 182.93)
Inverted and verno bring no tokens of their own. Inverted borrows
--gray-1000 and --background-100 from the theme; verno carries its three
dusk constants inline, the way a logo carries its colors.
Source
One file, one dependency: cva decides the classes, your cn merges them.
badgeVariants is exported on its own for the rare surface that needs the
look without the element.
import { cva } from "class-variance-authority";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const badgeVariants = cva(
"inline-flex shrink-0 items-center whitespace-nowrap rounded-full font-medium tabular-nums",
{
compoundVariants: [
{
className: "bg-badge-amber-subtle text-badge-amber-subtle-foreground",
contrast: "low",
variant: "amber",
},
{
className: "bg-badge-blue-subtle text-badge-blue-subtle-foreground",
contrast: "low",
variant: "blue",
},
{
className: "bg-badge-gray-subtle text-badge-gray-subtle-foreground",
contrast: "low",
variant: "gray",
},
{
className: "bg-badge-green-subtle text-badge-green-subtle-foreground",
contrast: "low",
variant: "green",
},
{
className: "bg-badge-pink-subtle text-badge-pink-subtle-foreground",
contrast: "low",
variant: "pink",
},
{
className: "bg-badge-purple-subtle text-badge-purple-subtle-foreground",
contrast: "low",
variant: "purple",
},
{
className: "bg-badge-red-subtle text-badge-red-subtle-foreground",
contrast: "low",
variant: "red",
},
{
className: "bg-badge-teal-subtle text-badge-teal-subtle-foreground",
contrast: "low",
variant: "teal",
},
],
defaultVariants: {
size: "md",
variant: "gray",
},
variants: {
contrast: {
low: "",
},
size: {
lg: "h-8 gap-1.5 px-3 text-sm [&_svg]:size-4",
md: "h-6 gap-1 px-2.5 text-xs [&_svg]:size-3.5",
sm: "h-5 gap-1 px-2 text-[0.6875rem] [&_svg]:size-3",
},
variant: {
amber: "bg-badge-amber text-black",
blue: "bg-badge-blue text-white",
gray: "bg-badge-gray text-white",
green: "bg-badge-green text-white",
inverted: "bg-gray-1000 text-background-100",
pink: "bg-badge-pink text-white",
purple: "bg-badge-purple text-white",
red: "bg-badge-red text-white",
teal: "bg-badge-teal text-white",
verno: "bg-linear-to-r from-[#0a5dab] via-[#124276] to-[#c25329] text-white",
},
},
},
);
interface BadgeProps {
readonly children: ReactNode;
readonly className?: string;
readonly contrast?: "low";
readonly icon?: ReactNode;
readonly size?: "sm" | "md" | "lg";
readonly variant?:
| "gray"
| "blue"
| "purple"
| "amber"
| "red"
| "pink"
| "green"
| "teal"
| "inverted"
| "verno";
}
export const Badge = ({
children,
className,
contrast,
icon,
size = "md",
variant = "gray",
}: BadgeProps) => (
<span className={cn(badgeVariants({ contrast, size, variant }), className)}>
{icon ? (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center">
{icon}
</span>
) : null}
{children}
</span>
);