Components

Badge

A compact label for status, counts, tags, and filters. Six semantic colors, three sizes. Composable leading slot (dot, flag, avatar, icon) plus an optional dismiss button.

Preview#

LiveFailedv0.1

Installation#

Same package as the rest of Foren.

bash
npm install @foren/ui
tsx
import { ForenBadge } from "@foren/ui";

Usage#

The minimum case.

tsx
<ForenBadge>Label</ForenBadge>
<ForenBadge color="success">Active</ForenBadge>
<ForenBadge color="error" onDismiss={() => removeTag(id)}>spam</ForenBadge>

Source#

Drop-in source if you'd rather skip the package.

components/ui/foren-badge.tsx
"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center justify-center gap-1.5 rounded-full font-medium whitespace-nowrap select-none [&_svg]:shrink-0 [&_svg]:pointer-events-none transition-colors",
  {
    variants: {
      color: {
        gray: "bg-[var(--color-foren-bg-button-secondary)] text-[var(--color-foren-text-secondary)]",
        primary: "bg-[var(--color-foren-bg-primary-subtle)] text-[var(--color-foren-text-primary)]",
        error: "bg-[var(--color-foren-bg-error-subtle)] text-[var(--color-foren-error)]",
        success: "bg-[var(--color-foren-bg-success-subtle)] text-[var(--color-foren-success)]",
        indigo: "bg-[var(--color-foren-bg-info-subtle)] text-[var(--color-foren-info)]",
        orange: "bg-[var(--color-foren-bg-warning-subtle)] text-[var(--color-foren-warning)]",
      },
      size: {
        sm: "h-[22px] px-2 text-[12px] leading-[16px] tracking-[0.04em] [&_svg]:size-[12px]",
        md: "h-6 px-2.5 text-[13px] leading-[18px] tracking-[0.01em] [&_svg]:size-[12px]",
        lg: "h-7 px-3 text-[14px] leading-[21px] tracking-[0.01em] [&_svg]:size-[14px]",
      },
      iconOnly: {
        true: "aspect-square !px-0",
        false: "",
      },
    },
    defaultVariants: { color: "gray", size: "md", iconOnly: false },
  }
);

const dotColors: Record<NonNullable<VariantProps<typeof badgeVariants>["color"]>, string> = {
  gray: "bg-[var(--color-foren-text-tertiary)]",
  primary: "bg-[var(--color-foren-primary-dot)]",
  error: "bg-[var(--color-foren-error)]",
  success: "bg-[var(--color-foren-success)]",
  indigo: "bg-[var(--color-foren-info)]",
  orange: "bg-[var(--color-foren-warning)]",
};

export type BadgeColor = NonNullable<VariantProps<typeof badgeVariants>["color"]>;

export interface ForenBadgeProps
  extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color">,
    Omit<VariantProps<typeof badgeVariants>, "iconOnly"> {
  /** Show a leading status dot tinted to the badge color. */
  dot?: boolean;
  /** Leading slot — flag, avatar img, icon. Wins over `dot` when both are set. */
  leadingIcon?: React.ReactNode;
  /** When provided, renders a dismiss "×" that calls this on click. */
  onDismiss?: () => void;
  /** Aria label for the dismiss button. */
  dismissLabel?: string;
  children?: React.ReactNode;
}

const XIcon = () => (
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
    <path d="M18 6 6 18" />
    <path d="m6 6 12 12" />
  </svg>
);

export const ForenBadge = React.forwardRef<HTMLSpanElement, ForenBadgeProps>(function ForenBadge(
  { className, color, size, dot, leadingIcon, onDismiss, dismissLabel = "Dismiss", children, ...props },
  ref
) {
  const iconOnly = children == null && (leadingIcon != null || dot);
  return (
    <span
      ref={ref}
      className={cn(badgeVariants({ color, size, iconOnly }), className)}
      {...props}
    >
      {!leadingIcon && dot && (
        <span aria-hidden className={cn("size-1.5 rounded-full", dotColors[color ?? "gray"])} />
      )}
      {leadingIcon}
      {children != null && <span>{children}</span>}
      {onDismiss && (
        <button
          type="button"
          aria-label={dismissLabel}
          onClick={(e) => {
            e.stopPropagation();
            onDismiss();
          }}
          className="-mr-1 inline-flex items-center justify-center size-4 rounded-full hover:bg-black/5 active:bg-black/10 transition-colors cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-current"
        >
          <span className="size-3">
            <XIcon />
          </span>
        </button>
      )}
    </span>
  );
});

export { badgeVariants };

Colors#

Six semantic colors — gray, primary, error, success, indigo, orange.

GrayPrimaryErrorSuccessIndigoOrange

Sizes#

Three sizes — sm, md (default), lg.

Size smSize mdSize lg

With dot#

A status dot, tinted to the badge color.

LabelLabelLabelLabelLabelLabel

With icon#

Pass any ReactNode to leadingIcon — Lucide, Heroicons, custom SVG.

LabelLabelLabelLabelLabelLabel

With flag#

Use the leading slot for flag emoji or country glyph.

🇬🇧United Kingdom🇺🇸United States🇯🇵Japan

With avatar#

Drop an avatar (img, initials) into the leading slot.

AYAyodeleMKMikeJSJess

Dismissible#

Pass onDismiss to render a trailing × button. Use for filters and tags.

FilterFilterFilterFilterFilterFilter

Icon only#

Omit children for a square icon-only badge — perfect for status dots or counts.

Full matrix#

Every color × every size. Inspect spacing and contrast.

gray
LabelLabelLabelLabelLabelLabel
primary
LabelLabelLabelLabelLabelLabel
error
LabelLabelLabelLabelLabelLabel
success
LabelLabelLabelLabelLabelLabel
indigo
LabelLabelLabelLabelLabelLabel
orange
LabelLabelLabelLabelLabelLabel

API#

PropTypeDefault
color"gray" | "primary" | "error" | "success" | "indigo" | "orange"
size"sm" | "md" | "lg"
dotboolean
leadingIconReactNode
onDismiss() => void
dismissLabelstring
…restHTMLAttributes<HTMLSpanElement>