# Badge

> Status labels with optional live dot and remove button.

Category: Data Display. Docs: https://get-neonkit.vercel.app/components/badge

## Installation

```bash
npx shadcn@latest add https://get-neonkit.vercel.app/r/badge.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/badge`.

npm dependencies: `lucide-react`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Badge } from "@/components/ui/badge"

<Badge color="green" pulse>Live</Badge>
```

## Examples

### Deployment status

dot adds a status light; pulse makes it ping.

```tsx
import { Badge } from "@/components/ui/badge";

const deployments = [
  { project: "neonkit-docs", branch: "main", commit: "a41f9c2", age: "2m ago", status: "live" },
  { project: "api-gateway", branch: "feat/rate-limits", commit: "7be0d13", age: "just now", status: "building" },
  { project: "marketing-site", branch: "fix/hero-video", commit: "c93e4a8", age: "14m ago", status: "failed" },
  { project: "billing-worker", branch: "main", commit: "5d2f61b", age: "40s ago", status: "queued" },
] as const;

const statuses = {
  live: { label: "Live", color: "green", pulse: true },
  building: { label: "Building", color: "amber", pulse: false },
  failed: { label: "Failed", color: "red", pulse: false },
  queued: { label: "Queued", color: "white", pulse: false },
} as const;

export default function BadgeDemo() {
  return (
    <ul className="w-full max-w-md divide-y divide-white/[0.06] rounded-xl border border-white/10 bg-white/[0.02]">
      {deployments.map((deployment) => {
        const status = statuses[deployment.status];
        return (
          <li key={deployment.project} className="flex items-center justify-between gap-4 px-4 py-3">
            <div className="min-w-0">
              <p className="truncate text-sm font-medium text-white/90">{deployment.project}</p>
              <p className="truncate font-mono text-xs text-white/40">
                {deployment.branch} · {deployment.commit} · {deployment.age}
              </p>
            </div>
            <Badge color={status.color} dot pulse={status.pulse}>
              {status.label}
            </Badge>
          </li>
        );
      })}
    </ul>
  );
}
```

### Variants and sizes

Soft is the default. Outline and solid carry more light.

```tsx
import { Badge } from "@/components/ui/badge";

const variants = ["soft", "outline", "solid"] as const;
const colors = ["cyan", "violet", "magenta", "amber", "lime"] as const;

export default function BadgeVariants() {
  return (
    <div className="grid w-full max-w-lg gap-4">
      {variants.map((variant) => (
        <div key={variant} className="flex flex-wrap items-center gap-2">
          <span className="w-16 font-mono text-xs text-white/40">{variant}</span>
          {colors.map((color) => (
            <Badge key={color} variant={variant} color={color} className="capitalize">
              {color}
            </Badge>
          ))}
        </div>
      ))}
      <div className="flex flex-wrap items-center gap-2">
        <span className="w-16 font-mono text-xs text-white/40">sizes</span>
        <Badge size="sm" dot>
          v2.0.0
        </Badge>
        <Badge size="md" dot>
          v2.0.0
        </Badge>
      </div>
    </div>
  );
}
```

### Removable

onRemove renders a small close button after the label.

```tsx
"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";

const initialFilters = ["TypeScript", "MIT license", "Updated this week", "Has examples"];

export default function BadgeRemovable() {
  const [filters, setFilters] = React.useState(initialFilters);

  return (
    <div className="grid w-full max-w-md gap-3">
      <div className="flex items-center justify-between">
        <p className="text-xs font-medium text-white/45">
          {filters.length} {filters.length === 1 ? "filter" : "filters"} applied
        </p>
        <Button
          variant="ghost"
          size="sm"
          disabled={filters.length === initialFilters.length}
          onClick={() => setFilters(initialFilters)}
        >
          Reset
        </Button>
      </div>
      <div className="flex min-h-6 flex-wrap gap-2">
        {filters.map((filter) => (
          <Badge
            key={filter}
            variant="outline"
            removeLabel={`Remove ${filter} filter`}
            onRemove={() => setFilters((current) => current.filter((item) => item !== filter))}
          >
            {filter}
          </Badge>
        ))}
        {filters.length === 0 && <p className="text-sm text-white/40">No filters. Showing every component.</p>}
      </div>
    </div>
  );
}
```

## API reference

### Badge

Renders a span and accepts every native span attribute.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant` | `"soft" \| "outline" \| "solid"` | `"soft"` | Visual style. |
| `color` | `NeonColor` | `inherited` | Palette name or hex. Falls back to --neon-accent, then cyan. |
| `size` | `"sm" \| "md"` | `"md"` | Height, padding and type size. |
| `dot` | `boolean` | `false` | Shows a glowing status dot before the label. |
| `pulse` | `boolean` | `false` | Adds a ping ring to the dot. Implies dot. |
| `onRemove` | `() => void` | — | Renders a remove button and calls this when it is pressed. |
| `removeLabel` | `string` | `"Remove"` | Accessible name for the remove button. |

## Accessibility

- The dot is decorative, so the label text has to carry the status on its own.
- Give each remove button a specific removeLabel, such as "Remove TypeScript filter", when several badges sit together.
- The ping animation stops when the user prefers reduced motion.

## Source

`components/ui/badge.tsx`

```tsx
"use client";

import * as React from "react";
import { X } from "lucide-react";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

const variants = {
  soft: "border-[rgb(var(--neon)/0.18)] bg-[rgb(var(--neon)/0.1)] text-[rgb(var(--neon))]",
  outline:
    "border-[rgb(var(--neon)/0.55)] text-[rgb(var(--neon))] shadow-[0_0_10px_rgb(var(--neon)/0.14),inset_0_0_8px_rgb(var(--neon)/0.08)] [text-shadow:0_0_8px_rgb(var(--neon)/0.45)]",
  solid:
    "border-transparent bg-[rgb(var(--neon))] text-[#05060a] shadow-[0_0_14px_rgb(var(--neon)/0.35),inset_0_1px_0_rgb(255_255_255/0.4)]",
} as const;

// Padding next to the dot and the remove button is tuned so both sit
// concentric with the pill's rounded ends (the 1px border is included).
const sizes = {
  sm: {
    root: "h-5 gap-1.5 px-2 text-[11px]",
    dot: "pl-1.5",
    remove: "pr-0.5",
    button: "size-3.5",
    icon: "size-2.5",
  },
  md: {
    root: "h-6 gap-1.5 px-2.5 text-xs",
    dot: "pl-2",
    remove: "pr-[3px]",
    button: "size-4",
    icon: "size-3",
  },
} as const;

export interface BadgeProps extends Omit<React.HTMLAttributes<HTMLSpanElement>, "color"> {
  /** Palette name or any hex color. Inherits `--neon-accent` when omitted. */
  color?: NeonColor;
  variant?: keyof typeof variants;
  size?: keyof typeof sizes;
  /** Shows a small glowing status dot before the label. */
  dot?: boolean;
  /** Adds a ping ring to the dot. Implies `dot`. */
  pulse?: boolean;
  /** Renders a remove button after the label. */
  onRemove?: () => void;
  /** Accessible name for the remove button. */
  removeLabel?: string;
}

const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(
  (
    {
      className,
      style,
      color,
      variant = "soft",
      size = "md",
      dot = false,
      pulse = false,
      onRemove,
      removeLabel = "Remove",
      children,
      ...props
    },
    ref,
  ) => {
    const s = sizes[size];
    const showDot = dot || pulse;

    return (
      <span
        ref={ref}
        style={neonVars(color, style)}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          "inline-flex shrink-0 items-center whitespace-nowrap rounded-full border font-medium leading-none",
          "[&>svg]:size-3 [&>svg]:shrink-0",
          s.root,
          showDot && s.dot,
          onRemove && s.remove,
          variants[variant],
          className,
        )}
        {...props}
      >
        {showDot && (
          <span aria-hidden className="relative flex size-1.5 shrink-0">
            {pulse && (
              <span className="absolute inset-0 animate-ping rounded-full bg-current opacity-60 [animation-duration:1.6s] motion-reduce:animate-none" />
            )}
            <span
              className={cn(
                "relative size-1.5 rounded-full bg-current",
                variant !== "solid" && "shadow-[0_0_6px_rgb(var(--neon)/0.9)]",
              )}
            />
          </span>
        )}
        {children}
        {onRemove && (
          <button
            type="button"
            aria-label={removeLabel}
            onClick={(event) => {
              event.stopPropagation();
              onRemove();
            }}
            className={cn(
              "relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full opacity-60 outline-none",
              "transition-[opacity,background-color] duration-150 ease-out hover:opacity-100 focus-visible:opacity-100",
              "focus-visible:ring-1 focus-visible:ring-current",
              // Grows the hit area without growing the badge.
              "after:absolute after:-inset-1",
              variant === "solid" ? "hover:bg-black/15" : "hover:bg-[rgb(var(--neon)/0.2)]",
              s.button,
            )}
          >
            <X aria-hidden strokeWidth={2.5} className={s.icon} />
          </button>
        )}
      </span>
    );
  },
);
Badge.displayName = "Badge";

export { Badge };
```
