Badge
Display a compact status, count, plan, or category. Badge renders a static span and stays out of the tab order. Add native ARIA attributes only when the label needs additional meaning.
The Badge is never the action. When a count belongs to notifications, messages, or logs, the enclosing Button or Link owns the destination, interaction, focus, and complete accessible name.
<script setup>
import Badge from '@/components/ui/badge/Badge.vue'
</script>
<template>
<Badge
class="bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
>
<span aria-hidden="true" class="size-1.5 rounded-full bg-emerald-500" />
Healthy
</Badge>
</template>import Badge from '@/components/ui/badge/Badge.jsx'
export default function ServiceStatus() {
return (
<Badge className="bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200">
<span
aria-hidden="true"
className="size-1.5 rounded-full bg-emerald-500"
/>
Healthy
</Badge>
)
}<script>
import Badge from '$lib/components/ui/badge/Badge.svelte'
</script>
<Badge
class="bg-emerald-50 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-200"
>
<span aria-hidden="true" class="size-1.5 rounded-full bg-emerald-500"></span>
Healthy
</Badge>View source Badge.vue
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const element = ref()
const forwardedAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
defineExpose({ element })
</script>
<template>
<span
ref="element"
v-bind="forwardedAttrs"
data-slot="badge"
:class="
twMerge(
'inline-flex items-center gap-1.5 rounded-full border border-transparent bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 text-nowrap forced-colors:border-current dark:bg-gray-800 dark:text-gray-300',
attrs.class
)
"
>
<slot />
</span>
</template>Installation
One command detects Vue, React, or Svelte and writes the matching one-file source into the conventional component directory:
Run from your application’s directory. Klean detects your framework and copies the matching source.
npx klean-ui add badgeUsage
Write the visible meaning in the content and put the product treatment directly on Badge with Tailwind.
Copy the example above for your framework.
API
| Input | Default | Purpose |
|---|---|---|
class / className | — | Ordinary Tailwind classes merged after the neutral monochrome baseline. |
| native span attributes | — | IDs, titles, ARIA attributes, data attributes, event hooks, and other native attributes. |
| default content | — | Visible text and optional ordinary inline markup. |
| element reference | — | Framework-native access to the rendered span when the application genuinely needs it. |
If the content must navigate, use a real anchor or framework Link. If it must perform work, use a real Button. Put Badge inside that control only when the metadata belongs to the control.
Notifications and counts
A notification Badge is useful, but it is still not a link or button. The parent control carries the complete accessible name, while the visual count is hidden from accessibility APIs so it is not announced twice.
View source NotificationButton.vue
<script setup>
import Button from '@/components/ui/button/Button.vue'
import Badge from '@/components/ui/badge/Badge.vue'
</script>
<template>
<Button
type="button"
aria-label="Notifications, 3 unread"
class="relative size-11 min-h-0 min-w-0 rounded-full p-0"
>
<svg aria-hidden="true" class="size-5"><!-- bell --></svg>
<Badge
aria-hidden="true"
class="absolute -right-1 -top-1 min-w-5 justify-center border-white bg-red-600 px-1 text-[10px] text-white"
>
3
</Badge>
</Button>
</template>
When terse text appears outside a completely labelled control, add context in the Badge content:
<p>
Inbox
<Badge>3 <span class="sr-only">unread messages</span></Badge>
</p>Styling with Tailwind
The default is deliberately neutral: a compact monochrome pill with a transparent high-contrast border. Caller classes can replace every visual choice.
<Badge
class="rounded-none border-2 border-black bg-black px-3 py-1 font-mono text-[10px] font-bold text-white uppercase tracking-[0.18em]"
>
Paid
</Badge>Keep repeated status-to-class maps in the application, next to the domain values they describe. A financial product may distinguish draft, sent, and paid; an infrastructure product may distinguish healthy, deploying, and failed. Klean does not pretend those taxonomies are universal variants.
Status recipes
Choose classes that match the meaning of each status in your application.
View source ProductBadges.vue
<script setup>
import Badge from '@/components/ui/badge/Badge.vue'
const statusClasses = {
draft: 'bg-gray-100 text-gray-700',
sent: 'bg-blue-50 text-blue-800',
paid: 'bg-emerald-50 text-emerald-800'
}
</script>
<template>
<Badge :class="statusClasses[invoice.status]">
{{ invoice.status }}
</Badge>
<Badge class="bg-emerald-50 px-3 py-1 text-emerald-700">
<span aria-hidden="true" class="size-1.5 rounded-full bg-emerald-500" />
Healthy
</Badge>
</template>
Accessibility
- Use visible words such as “Healthy,” “Paid,” or “Failed.” A dot or color alone does not carry meaning.
- Badge has no
role,aria-live, or tab stop by default. Static page metadata must not announce itself like a new event. - Add
sr-onlycontext when a terse count would otherwise be ambiguous. - When Badge sits inside an already labelled Button or Link, use
aria-hidden="true"if its content is already included in the parent's accessible name. - For a non-urgent status that changes after an operation, keep the same Badge mounted with
role="status",aria-live="polite", andaria-atomic="true", then update its text. The live region must exist before the change. - Use an Alert or existing page-level status region when the message needs more context than a compact label can hold.
- Let native forced-colors mode keep a visible boundary; do not remove the caller-visible text.
Durable behavior
Badge owns no state and needs none. Counts, statuses, and labels come from server data or application state, so server rendering and the next visit reproduce the truth without a browser-only cache.
If a Badge appears inside navigation, the real anchor or Boring Stack Link owns the URL. If it appears in a notification button, the surrounding application owns unread state and the complete accessible name. Badge adds no storage, query parameter, event listener, or hydration decision.
When to use
Use Badge for compact visible metadata beside or inside richer content: invoice state, service health, environment, plan, version, unread count, category, or a short feature label.
Use it when the text remains understandable at a glance and when the inline pill treatment improves scanning.
When not to use
- Use plain text when the pill adds no useful scanning boundary.
- Use Button for a command and a real anchor or Boring Stack Link for navigation.
- Use Alert for visible guidance, warnings, failures, or results that need explanatory content.
- Use Toast for a transient application event.
- Use Tabs or Menu when a compact label is actually choosing or navigating.
- Do not use Badge as a removable tag. Tags Input owns editing, keyboard removal, focus, and form state.
Complete framework source
Copy, inspect, and change the complete one-file source for your framework.
<script setup>
import { computed, ref, useAttrs } from 'vue'
import { twMerge } from 'tailwind-merge'
defineOptions({ inheritAttrs: false })
const attrs = useAttrs()
const element = ref()
const forwardedAttrs = computed(() => {
const { class: _class, 'data-slot': _dataSlot, ...rest } = attrs
return rest
})
defineExpose({ element })
</script>
<template>
<span
ref="element"
v-bind="forwardedAttrs"
data-slot="badge"
:class="
twMerge(
'inline-flex items-center gap-1.5 rounded-full border border-transparent bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 text-nowrap forced-colors:border-current dark:bg-gray-800 dark:text-gray-300',
attrs.class
)
"
>
<slot />
</span>
</template>import { forwardRef } from 'react'
import { twMerge } from 'tailwind-merge'
const BASE_CLASSES =
'inline-flex items-center gap-1.5 rounded-full border border-transparent bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 text-nowrap forced-colors:border-current dark:bg-gray-800 dark:text-gray-300'
const Badge = forwardRef(function Badge(
{ className, 'data-slot': _dataSlot, ...props },
ref
) {
return (
<span
{...props}
ref={ref}
data-slot="badge"
className={twMerge(BASE_CLASSES, className)}
/>
)
})
export default Badge<script>
import { twMerge } from "tailwind-merge";
const BASE_CLASSES =
"inline-flex items-center gap-1.5 rounded-full border border-transparent bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-700 text-nowrap forced-colors:border-current dark:bg-gray-800 dark:text-gray-300";
let {
children,
class: className,
"data-slot": _dataSlot,
...props
} = $props();
let element = $state();
export function getElement() {
return element;
}
</script>
<span
{...props}
bind:this={element}
data-slot="badge"
class={twMerge(BASE_CLASSES, className)}
>
{@render children?.()}
</span>Related components
- Button — owns notification commands and other interactive Badge compositions.
- Card and Table — supply the richer content where compact metadata often appears.
- Alert — communicates guidance, results, warnings, and failures that need a real content surface.
- Toast — announces transient application events instead of turning a static Badge into a notification system.
- Tooltip — supplements a semantic control when its visible label cannot carry enough context.