← Blog8 min read#tailwind#badge#chip

Badge, Chip and Tag Components in Tailwind: Status, Labels, Filters

Build badge, chip, and tag components in Tailwind CSS from scratch - status indicators, dismissible filters, and multi-variant label systems that actually scale.

colorful UI label tags and badge components on dark background

Badge, Chip, or Tag - Does the Name Even Matter?

Yes and no. In most design systems, these three terms get used interchangeably, but they do carry different intent. A badge is a status indicator - it tells you something happened ("3 unread", "LIVE", "ERROR"). A chip is an interactive element that represents a choice or a person - think Gmail's recipient chips, or a filter you can toggle. A tag is a label that describes something - article categories, product attributes, git labels. Different jobs. Similar markup.

In Tailwind, all three boil down to the same small inline container with a background, padding, and border-radius. The differences live in the interactions (dismissible? clickable? just static?) and the semantic color vocabulary. Get those two things right and you've got a system that scales across a whole app instead of a pile of one-off <span> elements.

Honestly, the naming debate is less important than the variant discipline. If your codebase has both a StatusBadge and an AlertBadge doing the same thing with different class strings, you've already lost. Pick one abstraction early. Tailwind v3.4 (released in 2024) made this even easier with the data-* variant syntax - more on that below.

Worth noting: this article focuses on React + Tailwind patterns, but the CSS is framework-agnostic. If you're using Vue or Svelte, the class strings are identical - just swap the JSX syntax.

The Simplest Badge: Tailwind from Zero

Start with the atomic case - a static status badge. You need four things: background color, text color, padding, and border-radius. Everything else is optional polish.

// Badge.tsx - minimal starting point
const statusMap = {
  success: 'bg-emerald-100 text-emerald-700 ring-1 ring-emerald-600/20',
  error:   'bg-red-100    text-red-700    ring-1 ring-red-600/20',
  warning: 'bg-amber-100  text-amber-700  ring-1 ring-amber-600/20',
  info:    'bg-sky-100    text-sky-700    ring-1 ring-sky-600/20',
  neutral: 'bg-gray-100   text-gray-600   ring-1 ring-gray-500/20',
} as const;

type Status = keyof typeof statusMap;

interface BadgeProps {
  status: Status;
  label: string;
  dot?: boolean;
}

export function Badge({ status, label, dot = false }: BadgeProps) {
  return (
    <span
      className={[
        'inline-flex items-center gap-x-1.5',
        'px-2 py-0.5 text-xs font-medium',
        'rounded-full',
        statusMap[status],
      ].join(' ')}
    >
      {dot && (
        <svg viewBox="0 0 6 6" className="h-1.5 w-1.5 fill-current" aria-hidden="true">
          <circle cx="3" cy="3" r="3" />
        </svg>
      )}
      {label}
    </span>
  );
}

The ring-1 trick is worth calling out. Instead of border border-emerald-200, using a single-pixel inset ring via Tailwind's ring utilities means the border doesn't add to the element's box model dimensions. Pixel-perfect layouts get unhappy with surprise border offsets, especially when badges sit inline with text.

That dot prop gives you the pulsing indicator look you see on Vercel's deployment status. If you want the actual pulse animation, wrap the dot in a <span className="relative flex"> with an animate-ping absolute clone - same trick GitHub uses for live build status, still looks great in 2026.

In practice, the statusMap object is your single source of truth. Every time a designer wants to add a "purple beta" badge, it's one line here, not a hunt through five files.

Chip Components: Interactive, Dismissible, Selectable

Chips are where it gets interesting. They're interactive - either toggleable filter chips ("JavaScript", "React", "TypeScript" in a tag cloud) or dismissible selection chips (like the recipient pills in an email composer). The key difference from a badge is that chips carry event handlers and maintain selected/active state.

// FilterChip.tsx
'use client';
import { useState } from 'react';

interface FilterChipProps {
  label: string;
  defaultSelected?: boolean;
  onChange?: (selected: boolean) => void;
}

export function FilterChip({ label, defaultSelected = false, onChange }: FilterChipProps) {
  const [selected, setSelected] = useState(defaultSelected);

  const toggle = () => {
    const next = !selected;
    setSelected(next);
    onChange?.(next);
  };

  return (
    <button
      type="button"
      onClick={toggle}
      aria-pressed={selected}
      className={[
        'inline-flex items-center gap-1.5',
        'px-3 py-1 text-sm font-medium rounded-full',
        'border transition-colors duration-150',
        'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
        selected
          ? 'bg-violet-600 text-white border-violet-600 focus-visible:ring-violet-500'
          : 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50 focus-visible:ring-gray-400',
      ].join(' ')}
    >
      {selected && (
        <svg className="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
          <path fillRule="evenodd" d="M16.704 4.153a.75.75 0 0 1 .143 1.052l-8 10.5a.75.75 0 0 1-1.127.075l-4.5-4.5a.75.75 0 0 1 1.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 0 1 1.05-.143Z" clipRule="evenodd" />
        </svg>
      )}
      {label}
    </button>
  );
}

The aria-pressed attribute is non-negotiable here. Screen readers need to know whether a chip is selected - without it, visually-impaired users have no idea whether their filter is active. This is one of those tiny things that takes 30 seconds to add and makes the difference between an accessible component and one that'll fail a WCAG 2.1 AA audit.

For dismissible chips - the "John Smith x" pattern in email composers - swap the checkmark icon for a close button as a child element. Make it a separate focusable <button> inside the chip wrapper so keyboard users can Tab into it independently. Don't nest interactive elements via onClick on a <div> and call it a day. That breaks keyboard navigation in every browser.

Quick aside: if you're building a filter panel with dozens of chips, consider lifting state up to a parent component with a Set<string> of active filters rather than letting each chip manage its own. Makes it trivial to wire up URL query params - ?tags=react,tailwind - for shareable filter state.

Tag Systems: Categories, Labels, and Multi-Color Variants

Tags are usually passive - they describe content rather than control it. Blog post categories, product attributes, GitHub issue labels, skill tags on a profile. The challenge with tags isn't the individual component, it's the variant system. Most apps end up with 10-20 tag "flavors" and you need a scalable way to manage colors without hardcoding a class string per category.

// Tag.tsx - color palette approach
const palette = [
  'bg-pink-100    text-pink-700',
  'bg-purple-100  text-purple-700',
  'bg-indigo-100  text-indigo-700',
  'bg-cyan-100    text-cyan-700',
  'bg-teal-100    text-teal-700',
  'bg-lime-100    text-lime-700',
  'bg-orange-100  text-orange-700',
  'bg-rose-100    text-rose-700',
] as const;

function hashLabel(label: string): number {
  let hash = 0;
  for (const char of label) hash = (hash * 31 + char.charCodeAt(0)) & 0xffff;
  return hash % palette.length;
}

interface TagProps {
  label: string;
  onRemove?: () => void;
}

export function Tag({ label, onRemove }: TagProps) {
  const colorClass = palette[hashLabel(label)];

  return (
    <span
      className={[
        'inline-flex items-center gap-1',
        'px-2.5 py-0.5 text-xs font-medium rounded-md',
        colorClass,
      ].join(' ')}
    >
      {label}
      {onRemove && (
        <button
          type="button"
          onClick={onRemove}
          className="ml-0.5 rounded hover:bg-black/10 focus:outline-none focus:ring-1 focus:ring-current"
          aria-label={`Remove ${label}`}
        >
          <svg className="h-3 w-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
            <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
          </svg>
        </button>
      )}
    </span>
  );
}

The hashLabel function is the clever bit. It deterministically maps a string to a color index, so "React" always gets indigo and "Python" always gets teal - consistent across sessions without storing any color config. It's a pattern GitHub uses for repository topics. Simple, effective, zero config.

One more thing - Tailwind's JIT compiler (default since v3.0) only includes classes it sees in your source at build time. If you're generating class strings dynamically by concatenating color names like 'bg-' + color + '-100', those classes won't be in your production bundle. The solution is exactly what the code above does: define the full class strings in a static array so Tailwind's content scanner can find them.

If you're pulling tag colors from a database or CMS, you've got two options: stick to a fixed palette like above, or switch to inline styles for the color properties and only use Tailwind for spacing and typography. Don't try to safeList thousands of dynamic color combinations - it bloats your CSS bundle unnecessarily. For most apps, a 8-12 color palette with deterministic assignment is exactly right.

Advanced: Tailwind Data Attributes and CVA Variants

Once your badge/chip/tag system grows past ~3 variants, class string concatenation gets unwieldy fast. Two tools make this dramatically cleaner: Tailwind's data-* variant support (landed in v3.2) and the class-variance-authority library (CVA). You'd use both in a real production component library.

// using CVA for a scalable badge system
import { cva, type VariantProps } from 'class-variance-authority';
import { clsx } from 'clsx';

const badge = cva(
  // base styles applied to every badge
  'inline-flex items-center gap-x-1.5 px-2 py-0.5 text-xs font-medium rounded-full ring-1 ring-inset',
  {
    variants: {
      color: {
        green:  'bg-green-50  text-green-700  ring-green-600/20',
        red:    'bg-red-50    text-red-700    ring-red-600/20',
        yellow: 'bg-yellow-50 text-yellow-700 ring-yellow-600/20',
        blue:   'bg-blue-50   text-blue-700   ring-blue-600/20',
        gray:   'bg-gray-50   text-gray-700   ring-gray-600/20',
        purple: 'bg-purple-50 text-purple-700 ring-purple-600/20',
      },
      size: {
        sm: 'px-1.5 py-0.5 text-xs',
        md: 'px-2.5 py-1   text-sm',
        lg: 'px-3   py-1.5 text-sm',
      },
    },
    defaultVariants: {
      color: 'gray',
      size: 'md',
    },
  }
);

type BadgeVariants = VariantProps<typeof badge>;

interface BadgeProps extends BadgeVariants {
  children: React.ReactNode;
  className?: string;
}

export function Badge({ color, size, className, children }: BadgeProps) {
  return (
    <span className={clsx(badge({ color, size }), className)}>
      {children}
    </span>
  );
}

// Usage:
// <Badge color="green" size="sm">Active</Badge>
// <Badge color="red">Error</Badge>

CVA gives you TypeScript autocomplete on variant props, which is genuinely useful when you're onboarding engineers to a shared component library. They get type errors if they pass color="teal" when teal isn't in your variant map - no runtime surprises. It also gives you the defaultVariants escape hatch so <Badge> without any props still renders something sane.

For theming across your whole app, pair this with semantic color tokens. The idea is that your badge variants reference tokens like --color-status-success instead of raw Tailwind colors, so a single CSS variable swap handles both light and dark mode for every badge in the app. Look at how the color system design article breaks down the token hierarchy - that pattern maps cleanly onto CVA variant definitions.

Look, you don't always need CVA. If you've got three badge variants and no plans to expand, a simple statusMap object is fine. Only pull in CVA when you're building something that multiple teams will use, or when your variant matrix starts looking like a spreadsheet.

Dark Mode, Accessibility, and Getting Details Right

Dark mode is where badge components frequently break. bg-emerald-100 text-emerald-700 looks great on a white background - on a dark gray #1a1a1a background, that pale green slab looks jarring and out of place. You've got two paths: use Tailwind's dark: variant to flip colors explicitly, or switch to a translucent approach that adapts automatically.

// Adaptive badge - works on both light and dark backgrounds
// Uses opacity-based colors that blend with the surface
const adaptiveStatusMap = {
  success: [
    'bg-emerald-500/15 text-emerald-400 ring-emerald-500/25',          // dark-bg friendly
    'dark:bg-emerald-400/10 dark:text-emerald-400 dark:ring-emerald-400/20', // explicit dark override
  ].join(' '),
  error: [
    'bg-red-500/15 text-red-400 ring-red-500/25',
    'dark:bg-red-400/10 dark:text-red-400 dark:ring-red-400/20',
  ].join(' '),
};

The opacity-based bg-emerald-500/15 approach - available since Tailwind v3.0's arbitrary value system - means your badge color inherits some of the background it sits on. In dark mode that makes them feel integrated rather than pasted on. Pair this with the Tailwind dark mode strategies and you've got a solid system with about 20 extra characters of markup.

On accessibility: contrast is the big one. WCAG 2.1 AA requires 4.5:1 for text under 18px. Most pale badge backgrounds (-100 scale) with medium text (-700 scale) are borderline - test them with a contrast checker, don't assume. The ring-1 border also helps because it adds a visible boundary even when the background contrast is low, separating the badge from surrounding content for low-vision users.

One more thing - don't forget role attributes for dynamic badges. If a badge shows a notification count that updates live, add aria-live="polite" to an outer wrapper so screen readers announce the change. A badge that says "3 new messages" and silently becomes "7" is invisible to assistive tech. Small fix, significant impact.

Building a Complete Filter Tag System for Search UIs

Real apps need chips that work as a group - a filter panel where you can select multiple options, clear all, and see the active set reflected in both the UI and the URL. Here's how you'd wire that up with React state and a useSearchParams hook in Next.js App Router.

// FilterTagGroup.tsx
'use client';
import { useRouter, useSearchParams, usePathname } from 'next/navigation';
import { useCallback } from 'react';

const TAGS = ['react', 'vue', 'tailwind', 'typescript', 'nextjs', 'animation'];

export function FilterTagGroup() {
  const router      = useRouter();
  const pathname    = usePathname();
  const searchParams = useSearchParams();

  const activeTags = new Set(searchParams.getAll('tag'));

  const toggleTag = useCallback(
    (tag: string) => {
      const next = new URLSearchParams(searchParams.toString());
      next.delete('tag');
      const updated = new Set(activeTags);
      updated.has(tag) ? updated.delete(tag) : updated.add(tag);
      updated.forEach(t => next.append('tag', t));
      router.push(`${pathname}?${next.toString()}`, { scroll: false });
    },
    [activeTags, pathname, router, searchParams]
  );

  const clearAll = () => {
    const next = new URLSearchParams(searchParams.toString());
    next.delete('tag');
    router.push(`${pathname}?${next.toString()}`, { scroll: false });
  };

  return (
    <div className="flex flex-wrap gap-2 items-center">
      {TAGS.map(tag => (
        <button
          key={tag}
          type="button"
          aria-pressed={activeTags.has(tag)}
          onClick={() => toggleTag(tag)}
          className={[
            'px-3 py-1 text-sm font-medium rounded-full border transition-all duration-150',
            activeTags.has(tag)
              ? 'bg-violet-600 text-white border-violet-600'
              : 'bg-transparent text-gray-600 border-gray-300 hover:border-gray-400',
          ].join(' ')}
        >
          {tag}
        </button>
      ))}
      {activeTags.size > 0 && (
        <button
          type="button"
          onClick={clearAll}
          className="text-sm text-gray-400 hover:text-gray-600 underline underline-offset-2"
        >
          Clear all
        </button>
      )}
    </div>
  );
}

The URL-sync pattern is underrated. It means users can share filtered results as a link, the browser back button works correctly, and you can read the active filters on the server in a Server Component without any client JS - searchParams.getAll('tag') in a Next.js page gives you the same array. Zero extra fetch.

That said, if your filter set is truly dynamic (loaded from an API, not a static list), you'd pass the tags array as a prop from a parent Server Component rather than hardcoding TAGS. The chip rendering logic stays identical.

Want to push the visual further? Check out what Empire UI ships for interactive filter components - there are pre-built chip systems with animated selection states, grouped filter panels, and variants matched to styles like glassmorphism and neobrutalism. Copy-paste starting points that already handle dark mode, WCAG contrast, and keyboard nav out of the box.

FAQ

What's the difference between a badge, chip, and tag in UI design?

Badges show status or counts ("3 unread", "LIVE"). Chips are interactive - toggleable filters or dismissible selections. Tags are passive descriptors - categories, labels, attributes. Same base markup, different interaction models.

Why won't my dynamic Tailwind badge colors show up in production?

Tailwind's JIT scanner only includes classes it finds as static strings in your source files. If you're concatenating class names dynamically like 'bg-' + color + '-100', those classes get tree-shaken out. Define full class strings in a static object or array instead.

How do I make a badge accessible to screen readers?

For static badges, descriptive text content is usually enough. For interactive chips, add aria-pressed to the button element. For live-updating notification badges, wrap them in a container with aria-live="polite" so changes are announced.

Should I use class-variance-authority (CVA) for badge variants?

Use CVA when you have more than 3-4 variants or when multiple teams share the component - TypeScript autocomplete on variant props pays off fast. For simple single-team usage, a plain status map object is easier to read and has zero dependencies.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

Tailwind CSS Mastery: Every Utility, Plugin, and Pattern in One Guide β†’10 Tailwind Component Patterns Every Developer Should Know β†’Card Component Variants in Tailwind: 10 Patterns for Every Use Case β†’Avatar Component in React: Initials Fallback, Status Badge, Group β†’