Bloom Logo

Badge

A compact label used to highlight status, categories, or contextual metadata. Supports multiple color palettes, visual variants, sizes, pressable interactions, and optional dot indicators or icon slots.

importimport { Badge } from "@/components/ui/badge/badge";
$npx @bloomui-react/cli add badge
badge.tsx
"use client";

import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { designRadius } from "@/lib/design-system";
import { useKeyboardClick } from "@/lib/hooks";
import { cn } from "@/lib/utils";

type BadgeColor =
  | "default"
  | "primary"
  | "secondary"
  | "accent"
  | "success"
  | "warning"
  | "danger"
  | "info";

type BadgeVariant =
  | "default"
  | "bordered"
  | "flat"
  | "ghost"
  | "shadow"
  | "dot";

type BadgeSize = "sm" | "md" | "lg";

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
  color?: BadgeColor;
  variant?: BadgeVariant;
  size?: BadgeSize;
  radius?: keyof typeof designRadius;
  startContent?: React.ReactNode;
  endContent?: React.ReactNode;
  dot?: boolean;
  isDot?: boolean;
  isPulsing?: boolean;
  isRemovable?: boolean;
  onRemove?: () => void;
  isPressable?: boolean;
  isDisabled?: boolean;
  isInvisible?: boolean;
  live?: boolean;
  asChild?: boolean;
}

const badgeSizes: Record<BadgeSize, string> = {
  sm: "px-2 py-0.5 text-xs gap-1",
  md: "px-2.5 py-1 text-xs gap-1.5",
  lg: "px-3 py-1.5 text-sm gap-2",
};

const badgeColorMap: Record<BadgeColor, Record<BadgeVariant, string>> = {
  default: {
    default: "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900",
    bordered:
      "border border-zinc-200 dark:border-zinc-800 text-zinc-900 dark:text-zinc-100 bg-transparent",
    flat: "bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 border border-transparent",
    ghost:
      "bg-transparent text-zinc-900 dark:text-zinc-100 border border-transparent",
    shadow:
      "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 shadow-md",
    dot: "bg-transparent text-zinc-900 dark:text-zinc-100 border border-transparent",
  },
  primary: {
    default: "bg-sky-600 text-white dark:bg-sky-500 dark:text-white",
    bordered:
      "border border-sky-500 text-sky-600 dark:text-sky-400 bg-transparent",
    flat: "bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-transparent",
    ghost:
      "bg-transparent text-sky-600 dark:text-sky-400 border border-transparent",
    shadow: "bg-sky-600 text-white dark:bg-sky-500 shadow-md shadow-sky-500/20",
    dot: "bg-transparent text-sky-600 dark:text-sky-400 border border-transparent",
  },
  secondary: {
    default: "bg-purple-600 text-white dark:bg-purple-500 dark:text-white",
    bordered:
      "border border-purple-500 text-purple-600 dark:text-purple-400 bg-transparent",
    flat: "bg-purple-500/15 text-purple-600 dark:text-purple-400 border border-transparent",
    ghost:
      "bg-transparent text-purple-600 dark:text-purple-400 border border-transparent",
    shadow:
      "bg-purple-600 text-white dark:bg-purple-500 shadow-md shadow-purple-500/20",
    dot: "bg-transparent text-purple-600 dark:text-purple-400 border border-transparent",
  },
  accent: {
    default: "bg-pink-600 text-white dark:bg-pink-500 dark:text-white",
    bordered:
      "border border-pink-500 text-pink-600 dark:text-pink-400 bg-transparent",
    flat: "bg-pink-500/15 text-pink-600 dark:text-pink-400 border border-transparent",
    ghost:
      "bg-transparent text-pink-600 dark:text-pink-400 border border-transparent",
    shadow:
      "bg-pink-600 text-white dark:bg-pink-500 shadow-md shadow-pink-500/20",
    dot: "bg-transparent text-pink-600 dark:text-pink-400 border border-transparent",
  },
  success: {
    default: "bg-emerald-600 text-white dark:bg-emerald-500 dark:text-white",
    bordered:
      "border border-emerald-500 text-emerald-600 dark:text-emerald-400 bg-transparent",
    flat: "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-transparent",
    ghost:
      "bg-transparent text-emerald-600 dark:text-emerald-400 border border-transparent",
    shadow:
      "bg-emerald-600 text-white dark:bg-emerald-500 shadow-md shadow-emerald-500/20",
    dot: "bg-transparent text-emerald-600 dark:text-emerald-400 border border-transparent",
  },
  warning: {
    default: "bg-amber-600 text-white dark:bg-amber-500 dark:text-white",
    bordered:
      "border border-amber-500 text-amber-600 dark:text-amber-400 bg-transparent",
    flat: "bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-transparent",
    ghost:
      "bg-transparent text-amber-600 dark:text-amber-400 border border-transparent",
    shadow:
      "bg-amber-600 text-white dark:bg-amber-500 shadow-md shadow-amber-500/20",
    dot: "bg-transparent text-amber-600 dark:text-amber-400 border border-transparent",
  },
  danger: {
    default: "bg-rose-600 text-white dark:bg-rose-500 dark:text-white",
    bordered:
      "border border-rose-500 text-rose-600 dark:text-rose-400 bg-transparent",
    flat: "bg-rose-500/15 text-rose-600 dark:text-rose-400 border border-transparent",
    ghost:
      "bg-transparent text-rose-600 dark:text-rose-400 border border-transparent",
    shadow:
      "bg-rose-600 text-white dark:bg-rose-500 shadow-md shadow-rose-500/20",
    dot: "bg-transparent text-rose-600 dark:text-rose-400 border border-transparent",
  },
  info: {
    default: "bg-blue-600 text-white dark:bg-blue-500 dark:text-white",
    bordered:
      "border border-blue-500 text-blue-600 dark:text-blue-400 bg-transparent",
    flat: "bg-blue-500/15 text-blue-600 dark:text-blue-400 border border-transparent",
    ghost:
      "bg-transparent text-blue-600 dark:text-blue-400 border border-transparent",
    shadow:
      "bg-blue-600 text-white dark:bg-blue-500 shadow-md shadow-blue-500/20",
    dot: "bg-transparent text-blue-600 dark:text-blue-400 border border-transparent",
  },
};

const dotColorMap: Record<BadgeColor, string> = {
  default: "bg-zinc-600 dark:bg-zinc-400",
  primary: "bg-sky-500",
  secondary: "bg-purple-500",
  accent: "bg-pink-500",
  success: "bg-emerald-500",
  warning: "bg-amber-500",
  danger: "bg-rose-500",
  info: "bg-blue-500",
};

const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
  (
    {
      color = "default",
      variant = "flat",
      size = "md",
      radius = "full",
      startContent,
      endContent,
      dot = false,
      isDot = false,
      isPulsing = false,
      isRemovable = false,
      onRemove,
      isPressable = false,
      isDisabled = false,
      isInvisible = false,
      live = false,
      children,
      className,
      asChild = false,
      ...props
    },
    ref,
  ) => {
    if (isInvisible) return null;

    const isOnlyDotMode = isDot && !children;
    const showDot = dot || isDot || variant === "dot";

    if (isOnlyDotMode) {
      const DotComp = asChild ? Slot : "span";
      return (
        <DotComp
          ref={ref}
          className={cn(
            "relative inline-flex shrink-0 size-2.5 rounded-full select-none",
            dotColorMap[color],
            isPulsing && "animate-pulse",
            className,
          )}
          {...props}
        >
          {isPulsing && (
            <span
              className={cn(
                "absolute inset-0 rounded-full animate-ping opacity-75",
                dotColorMap[color],
              )}
            />
          )}
        </DotComp>
      );
    }

    const Comp = asChild ? Slot : "span";
    const keyboardProps = useKeyboardClick<HTMLSpanElement>(
      isPressable && !isDisabled,
    );

    return (
      <Comp
        ref={ref}
        aria-live={live ? "polite" : undefined}
        aria-atomic={live ? "true" : undefined}
        role={isPressable ? "button" : undefined}
        className={cn(
          "inline-flex items-center font-semibold select-none transition-all duration-200 ease-in-out relative",
          badgeSizes[size],
          designRadius[radius],
          badgeColorMap[color][variant],
          isPressable &&
            !isDisabled &&
            "cursor-pointer hover:scale-105 active:scale-95 hover:opacity-90 will-change-transform outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring",
          isDisabled &&
            "opacity-50 grayscale cursor-not-allowed pointer-events-none",
          className,
        )}
        {...keyboardProps}
        {...props}
      >
        {showDot && (
          <span className="relative flex size-2 shrink-0">
            {isPulsing && (
              <span
                className={cn(
                  "absolute inset-0 rounded-full animate-ping opacity-75",
                  dotColorMap[color],
                )}
              />
            )}
            <span
              aria-hidden="true"
              className={cn(
                "size-2 rounded-full shrink-0 relative",
                dotColorMap[color],
              )}
            />
          </span>
        )}
        {startContent && (
          <span aria-hidden="true" className="shrink-0">
            {startContent}
          </span>
        )}
        {children && <span>{children}</span>}
        {endContent && (
          <span aria-hidden="true" className="shrink-0">
            {endContent}
          </span>
        )}
        {isRemovable && (
          <button
            type="button"
            onClick={(e) => {
              e.stopPropagation();
              onRemove?.();
            }}
            className="ml-1 -mr-1 p-0.5 rounded-full hover:bg-black/10 dark:hover:bg-white/20 transition-colors cursor-pointer"
            aria-label="Remove badge"
          >
            <svg
              className="size-3"
              fill="none"
              stroke="currentColor"
              viewBox="0 0 24 24"
            >
              <path
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
                d="M6 18L18 6M6 6l12 12"
              />
            </svg>
          </button>
        )}
      </Comp>
    );
  },
);

Badge.displayName = "Badge";

export type { BadgeColor, BadgeSize, BadgeVariant };
export { Badge };

Default

A standard badge component displaying a neutral status label.

Default Badge

Variants

Controls the visual appearance of the badge. flat is the default and works well for most contexts.

variant: default | bordered | flat | ghost | shadow | dot
DefaultBorderedFlatGhostShadowDot

Colors

Semantic color scale for the badge. Each color is shown across all six visual variants for clear comparison.

color: default | primary | secondary | accent | success | warning | danger
color="default"
FlatBorderedSolidGhostShadowDot
color="primary"
FlatBorderedSolidGhostShadowDot
color="secondary"
FlatBorderedSolidGhostShadowDot
color="accent"
FlatBorderedSolidGhostShadowDot
color="success"
FlatBorderedSolidGhostShadowDot
color="warning"
FlatBorderedSolidGhostShadowDot
color="danger"
FlatBorderedSolidGhostShadowDot

Sizes

Three size options to adapt to different UI contexts.

size: sm | md | lg
SmallMediumLarge

Radius

Control the border radius. Defaults to full for a pill shape.

radius: none | xs | sm | md | lg | xl | 2xl | 3xl | full
NoneSmallMediumLargeXLFull

Pressable

Add isPressable to make badges interactive with scale micro-animations and active press feedback.

isPressable: boolean
Clickable PrimaryClickable SuccessClickable Danger

Dot indicator

Adds a small colored dot before the label — useful for status or presence indicators.

dot: boolean
OnlineAwayOfflineUnknown

Icon slots

Use startContent or endContent to place icons or any ReactNode inside the badge.

startContent: ReactNodeendContent: ReactNode
VerifiedWarningNewError

Live Pulsing Status

Use isPulsing to render a live animated pulse ring effect for real-time status indicators (e.g. LIVE, Recording).

isPulsing: booleanisDot: boolean
LIVERecording SystemProcessing Stream
Server Online

Removable Tags

Render a dismiss close button inside the badge using isRemovable and onRemove.

isRemovable: booleanonRemove: () => void
ReactNext.jsTailwind

Props — Badge

Properties to configure the Badge component.

PropTypeDefaultDescription
color'default' | 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger''default'Sets the color theme of the badge.
variant'default' | 'bordered' | 'flat' | 'ghost' | 'shadow' | 'dot''flat'Sets the visual variant style.
size'sm' | 'md' | 'lg''md'Sets the size scale of the badge.
radius'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full''full'Sets border-radius of the badge.
isPressablebooleanfalseEnables interactive hover scale and click feedback.
isDisabledbooleanfalseDisables interaction and applies opacity filter.
isInvisiblebooleanfalseHides the badge when true.
dotbooleanfalseDisplays a small colored status dot before the content.
startContentReactNodeElement rendered before the badge label (e.g. an icon).
endContentReactNodeElement rendered after the badge label.
livebooleanfalseEnables aria-live="polite" region for dynamic status/count updates.