Badge
controlsNewFreeMITA small label for tier, category, new and demo data: neutral, free, pro, new and demo variants that read in light and dark.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/badge.jsonPreview
The preview could not load.
Live demo in the dark theme.
Code
Install
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/badge.jsonWith the registry added (components.json)
npx shadcn@latest add @manniche/badgeUsage
badge-demo.tsx
import { Badge } from '@/components/badge'
// Every variant, alone and the way they sit together on a gallery card. The "New" labels are example data.
export default function BadgeDemo() {
return (
<div className="grid w-full gap-6">
<div className="grid gap-3 rounded-[calc(var(--radius)*2+2px)] bg-card p-6 text-card-foreground shadow-[0_0_0_1px_color-mix(in_oklab,var(--foreground)_9%,transparent),0_1px_2px_rgba(0,0,0,0.03)]">
<p className="text-sm text-muted-foreground">Variants</p>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="neutral">controls</Badge>
<Badge variant="free">Free</Badge>
<Badge variant="pro">Pro</Badge>
<Badge variant="new">New</Badge>
<Badge variant="demo">Demo data</Badge>
</div>
<p className="mt-3 text-sm text-muted-foreground">Together, as on a card</p>
<div className="flex flex-wrap items-center gap-1.5">
<Badge>surfaces</Badge>
<Badge variant="new">New</Badge>
<Badge variant="pro">Pro</Badge>
</div>
<div className="flex flex-wrap items-center gap-1.5">
<Badge>agent</Badge>
<Badge variant="free">MIT</Badge>
</div>
</div>
<p className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<Badge variant="demo">Demo data</Badge>
The “New” labels above are examples; no dates exist yet.
</p>
</div>
)
}Source
badge.tsx
// A small label that says what kind of thing sits next to it: its tier, its category, that it is new, or that the
// figures around it are example data. One family: same height, same corner, one quiet look per meaning. It is never
// a button and never takes focus; when the label matters to a link, point the link's aria-describedby at its id.
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from '@/lib/utils'
export type BadgeVariant = 'neutral' | 'free' | 'pro' | 'new' | 'demo'
export type BadgeProps = Omit<ComponentPropsWithoutRef<'span'>, 'children'> & {
/**
* What the label means, which sets how loud it is:
* - `neutral`: a category or any plain tag ("controls", "surfaces"), set in mono on a faint fill. The default.
* - `free`: free or open source ("Free", "MIT"), a hairline outline that steps back.
* - `pro`: the paid tier ("Pro"), inverted to the page's text colour, so it is the one label that stands out.
* - `new`: recently added ("New"), a raised key with a small success dot. The word carries the meaning, not the dot.
* - `demo`: marks example data ("Demo data"), a dashed outline, so made-up figures never pass for real ones.
*/
variant?: BadgeVariant
/** The visible text. Keep it to a word or two. */
children: ReactNode
className?: string
}
const VARIANT: Record<BadgeVariant, string> = {
neutral: 'bg-foreground/[0.06] px-[7px] font-mono text-[11.5px] text-muted-foreground',
free: 'px-2 text-muted-foreground shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_16%,transparent)]',
pro: 'bg-foreground px-2 text-background shadow-[inset_0_1px_0_color-mix(in_oklab,var(--background)_35%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.2)]',
new: 'gap-1.5 bg-muted px-2 text-foreground shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_7%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.14),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)] dark:shadow-[inset_0_1px_0_color-mix(in_oklab,var(--foreground)_7%,transparent),inset_0_-1.5px_0_rgb(0_0_0/0.45),0_0_0_1px_color-mix(in_oklab,var(--foreground)_8%,transparent)]',
demo: 'border border-dashed border-foreground/25 px-[7px] text-muted-foreground',
}
/** A small, calm label for a tier, a category, something new or example data. Not interactive. */
export function Badge({ variant = 'neutral', children, className, ...rest }: BadgeProps) {
return (
<span
data-variant={variant}
className={cn(
'inline-flex h-[22px] flex-none items-center rounded-[6px] text-xs leading-none font-medium whitespace-nowrap',
VARIANT[variant],
className,
)}
{...rest}
>
{variant === 'new' && <span aria-hidden className="size-1.5 flex-none rounded-full bg-success" />}
{children}
</span>
)
}Props
BadgeProps
| Prop | Type | Default | What it does |
|---|---|---|---|
variant | BadgeVariant | 'neutral' | What the label means, which sets how loud it is:
|
children required | ReactNode | – | The visible text. Keep it to a word or two. |
className | string | – | – |
Also takes every prop from Omit<ComponentPropsWithoutRef<'span'>, 'children'>.
A11y
Screen reader
Plain text in a span, not interactive. Pass an id and point a link's aria-describedby at it, so the tier is read with the link.