Skip to content
Seamless UI

Badge

Save

A 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 badge

Pre-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-run

Usage

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

PropTypeDefaultDescription
tone"neutral" | "info" | "success" | "warning" | "danger""neutral"Status hue of the text, border tint, soft background, and dot. Neutral has no fill.
dotbooleanfalseShows a status dot before the label.
pulsebooleanfalseMarks 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.
...propsComponentProps<"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.tsxShow
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.vueShow
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>