Badge
SaveA compact status label with tinted tones, an optional status dot, and a still live halo for ongoing activity.
- Category
- Core
- Version
- 0.2.0
- Frameworks
- Next.js, Vite + React, Vite + Vue
- Styling
- Tailwind CSS v4
Preview
Badge preview
DraftSyncingLiveNeeds reviewFailedv0.2.0
Installation
npx @seamlessui/cli add badgePre-release@seamlessui/cli is not on npm yet. Install from a local build.
Review the plan first. A dry run lists every file and package without changing anything:
npx @seamlessui/cli add badge --dry-runUsage
badge-example.tsx
import { Badge } from "@/components/seamless/ui/badge";
export default function BadgeExample() {
return (
<div className="flex flex-wrap items-center justify-center gap-2">
<Badge>Draft</Badge>
<Badge tone="info" dot pulse>
Syncing
</Badge>
<Badge tone="success" dot>
Live
</Badge>
<Badge tone="warning" dot>
Needs review
</Badge>
<Badge tone="danger" dot>
Failed
</Badge>
<Badge className="font-mono tabular-nums">v0.2.0</Badge>
</div>
);
}API
| Prop | Type | Default | Description |
|---|---|---|---|
| tone | "neutral" | "info" | "success" | "warning" | "danger" | "neutral" | Status hue of the text, border tint, soft background, and dot. Neutral has no fill. |
| dot | boolean | false | Shows a status dot before the label. |
| pulse | boolean | false | Marks ongoing activity with a soft, still halo around the dot (no looping animation). Needs dot. The halo eases in and out when the prop changes. |
| ...props | ComponentProps<"span"> | — | Any native span attribute. |
Accessibility
- The label text carries the meaning; the dot and halo are decorative (aria-hidden).
- Light-mode text uses the -700 step of each hue so 12px labels meet WCAG AA contrast on the tint.
- Nothing loops: the live halo is static, and tone changes are a 200ms color transition that is removed with reduced motion.
Files
Installed into src/components/seamless, or components/seamless when the project has no src directory.
src/components/seamless/ui/badge.tsxbadge- npm packages: noneReact and Tailwind CSS v4 required
Source
The exact source the CLI installs, from /r/items/react/badge/0.2.0.json.
ui/badge.tsxShowHide
ui/badge.tsx
import type { ComponentProps } from "react";
function cx(...classes: (string | false | null | undefined)[]) {
return classes.filter(Boolean).join(" ");
}
// Text uses the -700 step in light mode so 12px labels pass WCAG AA on the soft tint.
const tones = {
neutral: "border-zinc-950/10 text-zinc-700 dark:border-white/15 dark:text-zinc-300",
info: "border-sky-500/35 bg-sky-500/[0.08] text-sky-700 dark:text-sky-400",
success: "border-emerald-500/30 bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-400",
warning: "border-amber-500/35 bg-amber-500/[0.08] text-amber-700 dark:text-amber-400",
danger: "border-red-500/35 bg-red-500/[0.08] text-red-700 dark:text-red-400",
} as const;
const dots = {
neutral: "bg-zinc-400 dark:bg-zinc-500",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
} as const;
// A still halo around the dot: reads as "live" without looping motion.
const halos = {
neutral: "ring-zinc-400/25 dark:ring-zinc-500/30",
info: "ring-sky-500/25",
success: "ring-emerald-500/25",
warning: "ring-amber-500/25",
danger: "ring-red-500/25",
} as const;
export type BadgeTone = keyof typeof tones;
export interface BadgeProps extends ComponentProps<"span"> {
tone?: BadgeTone;
/** Shows a status dot before the label. */
dot?: boolean;
/**
* Marks ongoing activity with a soft halo around the dot. The halo is still (no looping
* animation); it eases in and out when the prop changes. Needs `dot`.
*/
pulse?: boolean;
}
export function Badge({ tone = "neutral", dot = false, pulse = false, className, children, ...props }: BadgeProps) {
return (
<span
data-tone={tone}
data-live={(dot && pulse) || undefined}
className={cx(
"inline-flex h-5.5 items-center gap-1.5 rounded-md border px-2 text-xs leading-none font-medium whitespace-nowrap",
"transition-[background-color,border-color,color] duration-200 ease-out motion-reduce:transition-none",
"[&_svg]:size-3 [&_svg]:shrink-0",
tones[tone],
className,
)}
{...props}
>
{dot ? (
<span
aria-hidden="true"
className={cx(
"size-1.5 shrink-0 rounded-full",
"transition-[background-color,box-shadow] duration-200 ease-out motion-reduce:transition-none",
dots[tone],
pulse ? cx("ring-[3px]", halos[tone]) : "ring-0 ring-transparent",
)}
/>
) : null}
{children}
</span>
);
}Vue
A native Vue 3 implementation ships in the Vue pilot. Run the same command in a Vite + Vue project; the CLI detects Vue and installs these files instead. The live preview above is the React version; the Vue example below is built in a clean Vite + Vue app by CI.
badge-example.vue
<script setup lang="ts">
import Badge from "@/components/seamless/ui/Badge.vue";
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<Badge>Draft</Badge>
<Badge tone="info" dot pulse>Syncing</Badge>
<Badge tone="success" dot>Live</Badge>
<Badge tone="danger" dot>Failed</Badge>
</div>
</template>src/components/seamless/ui/Badge.vuevue/badge@0.2.0
ui/Badge.vueShowHide
ui/Badge.vue
<script lang="ts">
export type BadgeTone = "neutral" | "info" | "success" | "warning" | "danger";
</script>
<script setup lang="ts">
const props = withDefaults(
defineProps<{
tone?: BadgeTone;
/** Shows a status dot before the label. */
dot?: boolean;
/**
* Marks ongoing activity with a soft halo around the dot. The halo is still (no looping
* animation); it eases in and out when the prop changes. Needs `dot`.
*/
pulse?: boolean;
}>(),
{ tone: "neutral", dot: false, pulse: false },
);
// Text uses the -700 step in light mode so 12px labels pass WCAG AA on the soft tint.
const tones: Record<BadgeTone, string> = {
neutral: "border-zinc-950/10 text-zinc-700 dark:border-white/15 dark:text-zinc-300",
info: "border-sky-500/35 bg-sky-500/[0.08] text-sky-700 dark:text-sky-400",
success: "border-emerald-500/30 bg-emerald-500/[0.08] text-emerald-700 dark:text-emerald-400",
warning: "border-amber-500/35 bg-amber-500/[0.08] text-amber-700 dark:text-amber-400",
danger: "border-red-500/35 bg-red-500/[0.08] text-red-700 dark:text-red-400",
};
const dots: Record<BadgeTone, string> = {
neutral: "bg-zinc-400 dark:bg-zinc-500",
info: "bg-sky-500",
success: "bg-emerald-500",
warning: "bg-amber-500",
danger: "bg-red-500",
};
// A still halo around the dot: reads as "live" without looping motion.
const halos: Record<BadgeTone, string> = {
neutral: "ring-zinc-400/25 dark:ring-zinc-500/30",
info: "ring-sky-500/25",
success: "ring-emerald-500/25",
warning: "ring-amber-500/25",
danger: "ring-red-500/25",
};
</script>
<template>
<span
:data-tone="props.tone"
:data-live="(props.dot && props.pulse) || undefined"
:class="[
'inline-flex h-5.5 items-center gap-1.5 rounded-md border px-2 text-xs leading-none font-medium whitespace-nowrap',
'transition-[background-color,border-color,color] duration-200 ease-out motion-reduce:transition-none',
'[&_svg]:size-3 [&_svg]:shrink-0',
tones[props.tone],
]"
>
<span
v-if="props.dot"
aria-hidden="true"
:class="[
'size-1.5 shrink-0 rounded-full',
'transition-[background-color,box-shadow] duration-200 ease-out motion-reduce:transition-none',
dots[props.tone],
props.pulse ? ['ring-[3px]', halos[props.tone]] : 'ring-0 ring-transparent',
]"
/>
<slot />
</span>
</template>