All resources
CSS & TailwindIntermediate2 min read

Tailwind CSS Component Patterns

Merge classes safely, model variants, style by state with group and peer, and avoid the dynamic class name trap.

Merge classes with cn()

Components should accept a className prop, but naive concatenation produces conflicts like px-4 px-2. clsx handles conditionals and tailwind-merge resolves conflicts so the last class wins. This project exposes both as cn() in src/lib/utils.ts.

src/lib/utils.tsTypeScript
import { type ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge";export function cn(...inputs: ClassValue[]) {  return twMerge(clsx(inputs));}cn("px-4 py-2", isActive && "bg-white/10", "px-2");// → "py-2 bg-white/10 px-2"

Model variants instead of ad-hoc props

class-variance-authority turns design decisions into a typed API. Each variant is a closed set of options, so invalid combinations fail at compile time.

TypeScript
import { cva, type VariantProps } from "class-variance-authority";export const badgeVariants = cva(  "inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",  {    variants: {      tone: {        neutral: "bg-white/10 text-zinc-200",        success: "bg-emerald-500/15 text-emerald-300",        danger: "bg-red-500/15 text-red-300",      },    },    defaultVariants: { tone: "neutral" },  });export type BadgeProps = VariantProps<typeof badgeVariants>;

Style by state with group, peer and data attributes

  • group on a parent + group-hover: on children reacts to hovering the whole card.
  • peer on an input + peer-checked: on a sibling builds custom checkboxes without JavaScript.
  • data-[state=open]: pairs perfectly with Radix primitives, which expose their state as attributes.
  • aria-[current=page]: styles the active link from the same attribute screen readers use.
TSX
<label className="flex items-center gap-2">  <input type="checkbox" className="peer sr-only" />  <span className="size-5 rounded border border-white/20 peer-checked:bg-blue-500 peer-focus-visible:ring-2" />  Remember me</label>

Never build class names dynamically

Tailwind scans source files for complete class strings. A class assembled at runtime never appears in the source, so it is missing from the generated CSS.

TSX
// ✗ "bg-red-500" is never generated<span className={`bg-${color}-500`} />// ✓ map values to complete class namesconst TONES = {  red: "bg-red-500",  green: "bg-green-500",} as const;<span className={TONES[color]} />