Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Styling: CSS-in-JS, Tailwind, and CSS Modules

For .NET engineers who know: Plain CSS, Bootstrap, or a component library like Telerik/DevExpress in web projects; Blazor component isolation via .razor.css files You’ll learn: The major styling approaches in modern JS projects, why Tailwind is different from Bootstrap, and how to use it effectively Time: 10-15 minutes

The .NET Way (What You Already Know)

In .NET web projects, styling typically means one of a few approaches. A plain CSS file linked in _Layout.cshtml or wwwroot. Bootstrap loaded via CDN or LibMan, giving you utility classes and components. A third-party component library (Telerik UI, DevExpress, Syncfusion) that ships pre-styled components. Or, in Blazor, per-component isolation via a sidecar .razor.css file that Blazor scopes automatically.

/* MyComponent.razor.css — scoped to MyComponent automatically by Blazor */
.order-card {
  border: 1px solid #dee2e6;
  border-radius: 4px;
  padding: 16px;
}

.order-card .title {
  font-weight: 600;
  font-size: 1.125rem;
}
<!-- Blazor component — styles above apply only here -->
<div class="order-card">
  <h2 class="title">@Order.Id</h2>
</div>

The .NET ecosystem has a clear answer to “how do I style this”: either global CSS that you manage carefully to avoid conflicts, or isolation files that Blazor scopes for you. The JS ecosystem does not have one answer — it has five, each with different trade-offs. Understanding all five is necessary for reading existing codebases; knowing which one to use for new code matters for your team’s velocity.

The Modern JS/TS Way

Option 1: Plain CSS (Still Valid)

Plain .css files still work. Modern bundlers (Vite, webpack) import them directly:

/* orders.css */
.order-card {
  border: 1px solid #dee2e6;
  border-radius: 4px;
  padding: 16px;
}
// React component
import "./orders.css";

function OrderCard({ order }: { order: Order }) {
  return <div className="order-card">{order.id}</div>;
}

The problem with plain CSS in component-based apps is the same one that drove the creation of CSS Modules: all class names are global. .order-card in orders.css and .order-card in invoices.css are the same class. At scale, naming conventions (BEM, SMACSS) help, but they rely on discipline rather than tooling.

Option 2: CSS Modules (Scoped CSS)

CSS Modules solve the global namespace problem at the build step. Class names in a .module.css file are locally scoped by the bundler — the actual class names in the output are generated hashes that cannot conflict.

/* OrderCard.module.css */
.card {
  border: 1px solid #dee2e6;
  border-radius: 4px;
  padding: 16px;
}

.title {
  font-weight: 600;
  font-size: 1.125rem;
}

.statusBadge {
  display: inline-flex;
  align-items: center;
  padding: 2px 8px;
  border-radius: 9999px;
  font-size: 0.75rem;
}

.statusBadge.pending {
  background-color: #fef3c7;
  color: #92400e;
}

.statusBadge.fulfilled {
  background-color: #d1fae5;
  color: #065f46;
}
// OrderCard.tsx
import styles from "./OrderCard.module.css";

interface Order {
  id: number;
  total: number;
  status: "pending" | "processing" | "fulfilled" | "cancelled";
}

function OrderCard({ order }: { order: Order }) {
  return (
    <div className={styles.card}>
      <h2 className={styles.title}>Order #{order.id}</h2>
      <span className={`${styles.statusBadge} ${styles[order.status] ?? ""}`}>
        {order.status}
      </span>
      <p>${order.total.toFixed(2)}</p>
    </div>
  );
}

The styles.card reference becomes something like _card_1a2b3c in the output — unique per file. Two different component files can both have .card without conflict. This is the direct JS equivalent of Blazor’s .razor.css isolation, implemented at the bundler level.

CSS Modules have full TypeScript support via the typed-css-modules tool or the typescript-plugin-css-modules IDE plugin, which generates type declarations so styles.cardTypo fails at compile time.

Tailwind is a utility-first CSS framework. Instead of writing CSS classes that describe components (.order-card, .status-badge), you compose small single-purpose utility classes directly in your HTML.

// The same OrderCard, using Tailwind utilities
function OrderCard({ order }: { order: Order }) {
  const statusColors: Record<Order["status"], string> = {
    pending: "bg-amber-100 text-amber-800",
    processing: "bg-blue-100 text-blue-800",
    fulfilled: "bg-emerald-100 text-emerald-800",
    cancelled: "bg-red-100 text-red-800",
  };

  return (
    <div className="border border-gray-200 rounded-md p-4">
      <h2 className="font-semibold text-lg">Order #{order.id}</h2>
      <span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs ${statusColors[order.status]}`}>
        {order.status}
      </span>
      <p className="text-gray-700">${order.total.toFixed(2)}</p>
    </div>
  );
}

At first glance this looks like inline styles — it is not. Tailwind is a stylesheet at build time. The utility classes map to fixed design tokens (spacing scale, color palette, typography scale) defined in your configuration. p-4 is always padding: 1rem. text-lg is always font-size: 1.125rem; line-height: 1.75rem. They come from a design system, not from ad hoc values.

How Tailwind Works (Different from Bootstrap)

Bootstrap ships a pre-built CSS file with component classes (.btn, .card, .navbar). You use those classes as-is or override them. The full stylesheet is large even when trimmed.

Tailwind ships nothing pre-built. The build tool scans your source files for utility class names and generates a stylesheet containing only the classes you actually used. A production Tailwind stylesheet for a large app typically weighs 5-15KB. A full Bootstrap stylesheet is ~150KB.

# tailwind.config.ts — tells Tailwind where to look for class names
import type { Config } from "tailwindcss";

export default {
  content: [
    "./index.html",
    "./src/**/*.{ts,tsx,vue}",
  ],
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eff6ff",
          500: "#3b82f6",
          900: "#1e3a8a",
        },
      },
      fontFamily: {
        sans: ["Inter", "system-ui", "sans-serif"],
      },
    },
  },
  plugins: [],
} satisfies Config;

The content array is critical: Tailwind scans these files for class name strings. If a class name is constructed dynamically in a way Tailwind cannot see at build time, it will not be included in the output.

Responsive Utilities

Tailwind uses a mobile-first breakpoint system. Prefix any utility with a breakpoint to apply it at that screen size and above:

function ProductGrid() {
  return (
    <div
      className="
        grid
        grid-cols-1
        sm:grid-cols-2
        lg:grid-cols-3
        xl:grid-cols-4
        gap-4
        p-4
        lg:p-8
      "
    >
      {/* 1 column on mobile, 2 on small screens, 3 on large, 4 on XL */}
    </div>
  );
}

Breakpoints: sm (640px), md (768px), lg (1024px), xl (1280px), 2xl (1536px). All are min-width (mobile-first).

Dark Mode

// tailwind.config.ts — enable class-based dark mode
export default {
  darkMode: "class", // toggle by adding .dark to <html>
  // ...
};

// Component — dark: prefix applies in dark mode
function Card({ children }: { children: React.ReactNode }) {
  return (
    <div className="bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-lg p-6 shadow">
      {children}
    </div>
  );
}

Toggle dark mode by adding or removing the dark class on the root html element. Pair this with a Zustand/Pinia store that persists the preference to localStorage.

The @apply Escape Hatch

When you need to extract a repeated pattern into a named class — for a design system or for generated content that Tailwind cannot scan — use @apply:

/* globals.css or a component stylesheet */
@layer components {
  .btn-primary {
    @apply inline-flex items-center px-4 py-2 rounded-md
           bg-brand-500 text-white font-medium text-sm
           hover:bg-brand-600
           focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2
           disabled:opacity-50 disabled:cursor-not-allowed
           transition-colors duration-150;
  }
}

Use @apply sparingly. It reintroduces the indirection that Tailwind was designed to eliminate. Appropriate uses: base element styles (forms, typography, code blocks), third-party content you cannot annotate with classes, and established design system tokens you want to enforce by name. Do not use @apply as an escape hatch because utility classes “feel wrong” — that feeling passes.

Option 4: CSS-in-JS (styled-components / Emotion)

CSS-in-JS libraries let you write CSS inside TypeScript files as tagged template literals or object notation, scoped to a component automatically.

// styled-components
import styled from "styled-components";

interface CardProps {
  $variant?: "default" | "highlighted";
}

const Card = styled.div<CardProps>`
  border: 1px solid ${(props) => props.$variant === "highlighted" ? "#3b82f6" : "#dee2e6"};
  border-radius: 4px;
  padding: 16px;
  background: ${(props) => props.$variant === "highlighted" ? "#eff6ff" : "white"};
`;

const Title = styled.h2`
  font-weight: 600;
  font-size: 1.125rem;
  margin: 0;
`;

// Usage
function OrderCard({ order, highlighted }: { order: Order; highlighted?: boolean }) {
  return (
    <Card $variant={highlighted ? "highlighted" : "default"}>
      <Title>Order #{order.id}</Title>
    </Card>
  );
}

CSS-in-JS has genuine strengths: styles are collocated with components, TypeScript types flow into style props naturally, conditional styles are first-class. The trade-offs:

  • Runtime cost: styled-components and Emotion inject styles at runtime, adding JavaScript bundle weight and CPU cost on each render. This matters on low-end devices.
  • No static extraction without configuration: by default, styles are not in a separate CSS file; they are generated in JavaScript at render time.
  • Server rendering complexity: SSR requires additional setup to avoid style flash.

You will encounter styled-components in many existing React codebases (particularly those started between 2017 and 2022). Read it fluently. Do not start new projects with it unless the team has strong existing expertise and a specific reason to prefer it over Tailwind.

Option 5: Component Libraries (Covered in Article 3.8)

Libraries like shadcn/ui, Material UI, and Chakra UI provide pre-built components. Some are Tailwind-based, some have their own styling systems. Article 3.8 covers the component library ecosystem in depth.

Key Differences

.NET PatternJS/TS EquivalentNotes
Global CSS in wwwrootglobals.css imported in main.tsxSame concept
Blazor .razor.css isolationCSS Modules (.module.css)Both scope class names per component
Bootstrap component classesTailwind utilitiesNo pre-built components; compose utilities
Bootstrap override via SCSStailwind.config.ts theme.extendExtend the design system, do not override it
Inline styles (style="...")style={{ }} in JSXSame escapes to computed values; no utilities
CSS class naming discipline (BEM)CSS Modules, TailwindTooling enforces scoping instead
Telerik/DevExpress component themesComponent library design tokensEach library has its own theming API
SCSS variablesTailwind design tokens in configCSS custom properties also widely used
SCSS @mixinTailwind @apply or component abstractionUse sparingly

Gotchas for .NET Engineers

Gotcha 1: Dynamic Class Names Are Invisible to Tailwind

Tailwind’s build step scans source files as text. It does not execute your code. If you construct class names by concatenating strings at runtime, Tailwind cannot see the complete class name and will not include it in the output.

// BROKEN — Tailwind sees "bg-${color}-500", not "bg-red-500" or "bg-green-500"
function StatusDot({ color }: { color: string }) {
  return <div className={`bg-${color}-500 w-3 h-3 rounded-full`} />;
}

// CORRECT — always use complete, unbroken class name strings
const colorMap: Record<string, string> = {
  red: "bg-red-500",
  green: "bg-green-500",
  blue: "bg-blue-500",
  amber: "bg-amber-500",
};

function StatusDot({ color }: { color: keyof typeof colorMap }) {
  return <div className={`${colorMap[color]} w-3 h-3 rounded-full`} />;
}

Complete class name strings can appear anywhere in the codebase — in TypeScript, JSX, Vue templates, JSON files, whatever is listed in content. The rule is: the complete class name must appear as a string literal somewhere in a file Tailwind can scan.

Gotcha 2: Specificity Works Differently Than You Expect

In Bootstrap, you override component styles by writing more specific CSS selectors. In Tailwind, all utilities have the same low specificity — one class each. The last class in the cascade wins when utilities conflict.

This means utility ordering in a string does not matter for most cases, but it does matter when you compose utilities from a base set and try to override one:

// The issue: you want to extend a base button but override just the color
function Button({
  className,
  children,
}: {
  className?: string;
  children: React.ReactNode;
}) {
  return (
    <button
      className={`bg-blue-500 text-white px-4 py-2 rounded ${className}`}
    >
      {children}
    </button>
  );
}

// This does NOT reliably override bg-blue-500 with bg-red-500
// Both classes appear in the stylesheet; which one "wins" depends on
// their source order in the generated CSS, not on where they appear in the string
<Button className="bg-red-500">Danger</Button>

The correct solution is the clsx + tailwind-merge pattern:

import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

// Combine and merge Tailwind classes, resolving conflicts intelligently
function cn(...inputs: ClassValue[]): string {
  return twMerge(clsx(inputs));
}

// Now overrides work correctly — twMerge knows bg-red-500 and bg-blue-500
// are in the same "group" and keeps only the last one
function Button({ className, children }: { className?: string; children: React.ReactNode }) {
  return (
    <button className={cn("bg-blue-500 text-white px-4 py-2 rounded", className)}>
      {children}
    </button>
  );
}

<Button className="bg-red-500">Danger</Button> // bg-red-500 wins

The cn utility function is used in essentially every Tailwind-based component library and design system. You will see it everywhere. Install clsx and tailwind-merge at the start of every project.

Gotcha 3: CSS Modules Object Access Is Not Type-Safe Without Extra Setup

CSS Modules import as a plain JavaScript object at runtime. Without additional tooling, TypeScript types the entire styles import as Record<string, string>, which means typos compile silently:

import styles from "./OrderCard.module.css";

// TypeScript allows this even if .typo does not exist in the CSS file
<div className={styles.typo}>...</div>
// styles.typo === undefined at runtime; no class is applied; no error thrown

Fix this with typescript-plugin-css-modules in your tsconfig.json plugins and IDE configuration, or with typed-css-modules as a build step that generates .d.ts files from your .module.css files. Most teams using CSS Modules set this up from the start.

Gotcha 4: The Tailwind Purge Config Must Cover Every File That Uses Tailwind Classes

This bit teams transitioning from a manual stylesheet approach. If you add a new directory of components and forget to add it to content in tailwind.config.ts, those components will have no Tailwind styles in production (where unused classes are purged). In development mode, Tailwind includes all classes, which is why you miss the problem until you deploy.

// tailwind.config.ts — cover every location where Tailwind classes appear
export default {
  content: [
    "./index.html",
    "./src/**/*.{ts,tsx}",           // React
    "./src/**/*.{vue,ts}",           // Vue
    "./src/**/*.{svelte,ts}",        // Svelte
    "./node_modules/your-lib/**/*.js", // If a dependency uses Tailwind classes
  ],
  // ...
};

Any file path pattern added to the project (a features/ directory, a packages/ directory in a monorepo) needs a corresponding entry in content. Treat this like adding a new project reference — easy to forget, immediately visible when you deploy.

Gotcha 5: styled-components Props Convention Changed in v6

If you read existing code using styled-components and see prop names without a $ prefix used to pass styling props, it is pre-v6 code. In styled-components v6, props that are only for styling (not forwarded to the DOM element) must be prefixed with $ to prevent React’s “unknown prop” warning.

// styled-components v5 — prop forwarded to DOM, React warns about unknown prop
const Card = styled.div<{ variant: string }>`
  border: ${(p) => p.variant === "error" ? "1px solid red" : "1px solid gray"};
`;
<Card variant="error">...</Card> // React warns: "variant" is not a valid DOM attribute

// styled-components v6 — $ prefix marks it as a transient prop, not forwarded to DOM
const Card = styled.div<{ $variant: string }>`
  border: ${(p) => p.$variant === "error" ? "1px solid red" : "1px solid gray"};
`;
<Card $variant="error">...</Card> // No warning; $ is stripped before forwarding

When reading old styled-components code, non-$-prefixed props passed to styled components were either intentional DOM attributes or unintentional warnings. When writing new styled-components code, prefix all styling-only props with $.

Hands-On Exercise

Build a status badge component using each of the three main approaches to compare them.

Specification: A StatusBadge component that:

  • Accepts a status prop: "pending" | "processing" | "fulfilled" | "cancelled"
  • Renders a pill-shaped badge with an appropriate color per status
  • Colors: pending=amber, processing=blue, fulfilled=green, cancelled=red
  • Typography: small, semibold, uppercase

Exercise 1 — Tailwind approach:

// StatusBadge.tsx — implement using only Tailwind utilities
interface StatusBadgeProps {
  status: "pending" | "processing" | "fulfilled" | "cancelled";
}

// The cn() helper:
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: Parameters<typeof clsx>) => twMerge(clsx(inputs));

export function StatusBadge({ status }: StatusBadgeProps) {
  // TODO: implement using Tailwind
  // Hint: define a statusStyles object mapping status -> class string
  // Use cn() to combine the base classes with the status-specific classes
}

Exercise 2 — CSS Modules approach:

/* StatusBadge.module.css — implement the same visual result with CSS */
.badge {
  /* base styles */
}

.pending { /* amber */ }
.processing { /* blue */ }
.fulfilled { /* green */ }
.cancelled { /* red */ }
// StatusBadge.tsx with CSS Modules
import styles from "./StatusBadge.module.css";

export function StatusBadge({ status }: StatusBadgeProps) {
  // TODO: implement using CSS Modules
}

Exercise 3 — Responsive extension:

Extend the Tailwind version to:

  • On mobile: show just the colored dot, no text
  • On sm: and above: show the full text badge
// Hint: use Tailwind's responsive prefixes and conditional rendering
// sm:hidden / hidden sm:block for toggling visibility

Reference solution for Exercise 1:

export function StatusBadge({ status }: StatusBadgeProps) {
  const statusStyles: Record<StatusBadgeProps["status"], string> = {
    pending: "bg-amber-100 text-amber-800",
    processing: "bg-blue-100 text-blue-800",
    fulfilled: "bg-emerald-100 text-emerald-800",
    cancelled: "bg-red-100 text-red-800",
  };

  return (
    <span
      className={cn(
        "inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold uppercase tracking-wide",
        statusStyles[status]
      )}
    >
      {status}
    </span>
  );
}

Quick Reference

ApproachBest ForAvoid When
Plain CSSSimple pages, prototype, no component systemComponent library at any scale
CSS ModulesComponent-level scoping without Tailwind overheadTeam already uses Tailwind consistently
Tailwind CSSNew projects, design system, most React/Vue workStrong existing CSS/SCSS architecture
styled-components / EmotionReading legacy code, CSS-in-JS team preferencePerformance-critical SSR, new projects
BootstrapRapid prototype with minimal designNeed design system flexibility
Tailwind UtilityCSS EquivalentNotes
p-4padding: 1rem4 = 4 × 0.25rem = 1rem
px-4 py-2padding: 0.5rem 1remx = horizontal, y = vertical
m-automargin: autoCentering trick
flex items-center justify-betweenflexbox row, vertically centered, space-betweenCommon layout pattern
grid grid-cols-3 gap-4display:grid; grid-template-columns: repeat(3, 1fr); gap: 1rem
text-smfont-size: 0.875rem; line-height: 1.25remTypography scale
font-semiboldfont-weight: 600
rounded-mdborder-radius: 0.375rem
shadow-smSmall box shadow
hover:bg-blue-600:hover { background-color: ... }State prefix
focus:ring-2:focus { box-shadow: 0 0 0 2px ... }Accessibility outline
sm:hidden@media (min-width: 640px) { display: none }Responsive prefix
dark:bg-gray-800@media (prefers-color-scheme: dark) { ... }Dark mode prefix
disabled:opacity-50:disabled { opacity: 0.5 }Disabled state

Further Reading

  • Tailwind CSS Documentation — The official docs are excellent. Start with “Utility-First Fundamentals” and “Responsive Design”.
  • CSS Modules Specification — Short specification document explaining the scoping rules.
  • tailwind-merge — The library that resolves Tailwind class conflicts. Read the README for the rules it applies.
  • Tailwind CSS IntelliSense — VS Code extension. Autocomplete, hover previews, and linting for Tailwind. Install before you write a single line.
  • styled-components Documentation — Reference for reading existing code. The “API Reference” section covers the $-prefix transient props change.