Skip to content

Badge

Badge is one static inline label for compact metadata: a visible status, count, plan, environment, version, or category. It renders a span, stays out of the tab order, and says nothing to assistive technology beyond its content unless the application deliberately supplies native ARIA attributes.

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.

Badge.vue

Installation

One command detects Vue, React, or Svelte and writes the matching one-file source into the conventional component directory:

Run one command from a Boring Stack application. Klean detects the framework and conventional destination, then adds the framework-native source and its direct dependencies.

Terminal
npx klean-ui add badge

  • No initializer or configuration file
  • No framework, alias, or theme questions
  • No Klean runtime dependency

There is no initializer, provider, klean-ui.json, class helper, barrel file, variant package, or runtime Klean dependency.

Usage

Write the visible meaning in the content and put the product treatment directly on Badge with Tailwind.

Vue

ServiceStatus.vue
<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>

React

ServiceStatus.jsx
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>
  )
}

Svelte

ServiceStatus.svelte
<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>

API

InputDefaultPurpose
class / classNameOrdinary Tailwind classes merged after the neutral monochrome baseline.
native span attributesIDs, titles, ARIA attributes, data attributes, event hooks, and other native attributes.
default contentVisible text and optional ordinary inline markup.
element referenceFramework-native access to the rendered span when the application genuinely needs it.

There is no as, variant, severity, tone, status, color, size, pill, or removable API. Badge always renders one span because its contract is static inline metadata.

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.

NotificationButton.vue

When terse text appears outside a completely labelled control, add context in the Badge content:

vue
<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.

vue
<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.

Hagfish and Slipway recipes

The same Badge can keep Hagfish expressive and Slipway operational without teaching Klean either product's status model.

ProductBadges.vue
Hagfish owns invoice classes. Slipway owns service health and the surrounding logs button. The Badge remains one static span.

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-only context 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", and aria-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.

Vue 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>

React source

Badge.jsx
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

Svelte source

Badge.svelte
<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>

  • 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.

All open source projects are released under the MIT License.