Badge
Preview
Tones
Loading…
Preview
With a state dot
Loading…
Preview
Code
ts
import Badge from '@/components/data/Badge';src/components/data/Badge.tsx
AI prompt
text
Build a status badge (pill) component in React + TypeScript + Tailwind CSS.
## Look
- `inline-flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold leading-none ring-1 ring-inset`. `leading-none` matters: without it the badge inherits a table cell's tall line-height and stretches into an oval.
- Tones (background / text / ring, then dark):
- neutral: slate-100 / slate-700 / slate-200; dark slate-800 / slate-300 / slate-700.
- info: indigo-50 / indigo-700 / indigo-200; dark `indigo-500/10` / indigo-300 / `indigo-500/30`.
- success, warning, danger: the same recipe in emerald, amber and rose.
- `dot`: a 6px circle before the text, filled with `bg-current` at 70% opacity and `aria-hidden`. Use it when the badge shows a state rather than a count.
## API
`tone?: 'neutral' | 'info' | 'success' | 'warning' | 'danger'` (default neutral), `dot?: boolean` (default false), `className?`, `children`. Export the `BadgeTone` type.
## Demo
Two rows of all five tones, one plain and one with dots, e.g. "Draft", "Syncing", "Active", "Expiring", "Suspended".
## House style (applies to everything above)
- Stack: React 19 + TypeScript + Tailwind CSS v4, icons from lucide-react. One self-contained file; default-export the component and named-export its types. `'use client'` if it has state, refs or handlers.
- Font Inter; palette indigo on slate. Primary accent indigo-600 (hover indigo-700, dark mode indigo-400). Body text slate-700 / dark slate-200; secondary slate-500 / dark slate-400.
- Dark mode is a `.dark` class on <html> (not prefers-color-scheme). Every colour needs its `dark:` pair.
- Compact admin scale: text-xs (12px) for controls and body, 10–11px for meta, rounded-lg (8px) controls, rounded-2xl (16px) cards.
- Card surface ("panel"): `bg-white/60 dark:bg-slate-800/60 backdrop-blur-xl border border-white/60 dark:border-slate-700/60 rounded-2xl shadow-lg`, on a soft slate gradient page background.
- Floating surfaces (dropdowns, popovers, menus) are OPAQUE: `bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-2xl shadow-lg`, no backdrop blur (it creates a stacking context that traps the popover's z-index). In-flow popovers are z-50; portalled overlays z-200.
- Text inputs and select triggers: `w-full px-3 py-2 text-xs rounded-lg border border-slate-300 dark:border-slate-700 bg-white/80 dark:bg-slate-900/60 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-500`.
- Field labels: 11px semibold slate-600. Section titles: 10px semibold uppercase wide-tracking slate-500.
- Primary button: indigo-600 fill, white 12px semibold text, rounded-lg, px-3 py-2, disabled at 50% opacity. Ghost button: slate-600 text, hover slate-100.
- Popovers close on outside click AND on Escape (listen to both; include the portalled panel's element in the outside-click check).
- Don't nest scroll containers around popovers: an ancestor with overflow hidden/auto clips an absolutely-positioned dropdown. Portal the panel to <body> when it must escape a scroller, and reposition it on scroll and resize.
- Accessible by default: visible focus rings, keyboard support that matches the WAI-ARIA pattern for the widget, `aria-label` on icon-only buttons, `min-w-0` so text truncates instead of overflowing.Source
tsx
'use client';
import { cn } from '@/lib/cn';
const TONE = {
neutral: 'bg-slate-100 text-slate-700 ring-slate-200 dark:bg-slate-800 dark:text-slate-300 dark:ring-slate-700',
info: 'bg-indigo-50 text-indigo-700 ring-indigo-200 dark:bg-indigo-500/10 dark:text-indigo-300 dark:ring-indigo-500/30',
success: 'bg-emerald-50 text-emerald-700 ring-emerald-200 dark:bg-emerald-500/10 dark:text-emerald-300 dark:ring-emerald-500/30',
warning: 'bg-amber-50 text-amber-700 ring-amber-200 dark:bg-amber-500/10 dark:text-amber-300 dark:ring-amber-500/30',
danger: 'bg-rose-50 text-rose-700 ring-rose-200 dark:bg-rose-500/10 dark:text-rose-300 dark:ring-rose-500/30',
} as const;
export type BadgeTone = keyof typeof TONE;
/** A status pill. `dot` prefixes a filled circle for a state rather than a count. */
export default function Badge({
tone = 'neutral',
dot = false,
className,
children,
}: {
tone?: BadgeTone;
dot?: boolean;
className?: string;
children: React.ReactNode;
}) {
return (
<span
className={cn(
// `leading-none` is load-bearing, not tidying. The density rules set a
// line-height of 32-48px on every table cell, and an inline-flex badge
// inherits it — which stretches the pill into an oval the moment one is
// used inside a table, which is most of the time.
'inline-flex items-center gap-1 rounded-full px-2 py-1 text-[10px] font-semibold leading-none ring-1 ring-inset',
TONE[tone],
className,
)}
>
{dot && <span className="h-1.5 w-1.5 rounded-full bg-current opacity-70" aria-hidden />}
{children}
</span>
);
}
Props
| Prop | Type | Default | Description |
|---|---|---|---|
children* | React.ReactNode | — | |
tone | BadgeTone | 'neutral' | |
dot | boolean | false | |
className | string | — |