Status Badge System

A tokenized status-badge system — one Status type, one TOKENS record, two sizes (sm/md) and two variants (dot/icon). Adding a new status only requires extending the token record, not editing the component. Inspired by Linear.

Buttons & BadgesInspired by Linear
Live PreviewInteractive
Linear · Status tokens

All statuses

TodoIn ProgressIn ReviewDoneCanceledBlocked

Sizes

sm
In ProgressDoneBlocked
md
In ProgressDoneBlocked

Variants

dot
TodoIn ProgressIn ReviewDoneCanceledBlocked
icon
TodoIn ProgressIn ReviewDoneCanceledBlocked

One token set drives every status · Same API for dot and icon variants, two sizes

StatusBadge.tsx
// Dependencies: react ^18, lucide-react
import React from 'react';
import {
  Circle, Clock, Eye, CheckCircle2, XCircle, AlertOctagon,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';

function cn(...classes: (string | false | null | undefined)[]) {
  return classes.filter(Boolean).join(' ');
}

export type Status =
  | 'todo'
  | 'in-progress'
  | 'in-review'
  | 'done'
  | 'canceled'
  | 'blocked';

type Token = {
  label: string;
  icon: LucideIcon;
  dot: string;
  iconClass: string;
  bg: string;
  border: string;
  text: string;
};

export const STATUS_TOKENS: Record<Status, Token> = {
  todo: {
    label: 'Todo', icon: Circle,
    dot: 'bg-zinc-400', iconClass: 'text-zinc-400',
    bg: 'bg-zinc-500/10', border: 'border-zinc-500/20', text: 'text-zinc-300',
  },
  'in-progress': {
    label: 'In Progress', icon: Clock,
    dot: 'bg-amber-400', iconClass: 'text-amber-400',
    bg: 'bg-amber-500/10', border: 'border-amber-500/20', text: 'text-amber-300',
  },
  'in-review': {
    label: 'In Review', icon: Eye,
    dot: 'bg-indigo-400', iconClass: 'text-indigo-400',
    bg: 'bg-indigo-500/10', border: 'border-indigo-500/20', text: 'text-indigo-300',
  },
  done: {
    label: 'Done', icon: CheckCircle2,
    dot: 'bg-emerald-400', iconClass: 'text-emerald-400',
    bg: 'bg-emerald-500/10', border: 'border-emerald-500/20', text: 'text-emerald-300',
  },
  canceled: {
    label: 'Canceled', icon: XCircle,
    dot: 'bg-zinc-600', iconClass: 'text-zinc-500',
    bg: 'bg-zinc-700/30', border: 'border-zinc-600/40', text: 'text-zinc-500',
  },
  blocked: {
    label: 'Blocked', icon: AlertOctagon,
    dot: 'bg-red-400', iconClass: 'text-red-400',
    bg: 'bg-red-500/10', border: 'border-red-500/20', text: 'text-red-300',
  },
};

type Props = {
  status: Status;
  size?: 'sm' | 'md';
  variant?: 'dot' | 'icon';
};

export function StatusBadge({ status, size = 'md', variant = 'dot' }: Props) {
  const t = STATUS_TOKENS[status];

  const sizeCls =
    size === 'sm'
      ? 'px-1.5 py-0.5 text-[10px] gap-1'
      : 'px-2 py-0.5 text-[11px] gap-1.5';
  const dotSize = size === 'sm' ? 'w-1.5 h-1.5' : 'w-2 h-2';
  const iconSize = size === 'sm' ? 'w-2.5 h-2.5' : 'w-3 h-3';

  return (
    <span
      className={cn(
        'inline-flex items-center rounded-full border font-medium whitespace-nowrap',
        t.bg, t.border, t.text, sizeCls
      )}
    >
      {variant === 'dot' ? (
        <span className={cn('rounded-full shrink-0', dotSize, t.dot)} />
      ) : (
        <t.icon className={cn('shrink-0', iconSize, t.iconClass)} />
      )}
      {t.label}
    </span>
  );
}