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.cssfiles 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.
Option 3: Tailwind CSS (Recommended — Primary Choice)
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 Pattern | JS/TS Equivalent | Notes |
|---|---|---|
Global CSS in wwwroot | globals.css imported in main.tsx | Same concept |
Blazor .razor.css isolation | CSS Modules (.module.css) | Both scope class names per component |
| Bootstrap component classes | Tailwind utilities | No pre-built components; compose utilities |
| Bootstrap override via SCSS | tailwind.config.ts theme.extend | Extend the design system, do not override it |
Inline styles (style="...") | style={{ }} in JSX | Same escapes to computed values; no utilities |
| CSS class naming discipline (BEM) | CSS Modules, Tailwind | Tooling enforces scoping instead |
| Telerik/DevExpress component themes | Component library design tokens | Each library has its own theming API |
| SCSS variables | Tailwind design tokens in config | CSS custom properties also widely used |
SCSS @mixin | Tailwind @apply or component abstraction | Use 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
statusprop:"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
| Approach | Best For | Avoid When |
|---|---|---|
| Plain CSS | Simple pages, prototype, no component system | Component library at any scale |
| CSS Modules | Component-level scoping without Tailwind overhead | Team already uses Tailwind consistently |
| Tailwind CSS | New projects, design system, most React/Vue work | Strong existing CSS/SCSS architecture |
| styled-components / Emotion | Reading legacy code, CSS-in-JS team preference | Performance-critical SSR, new projects |
| Bootstrap | Rapid prototype with minimal design | Need design system flexibility |
| Tailwind Utility | CSS Equivalent | Notes |
|---|---|---|
p-4 | padding: 1rem | 4 = 4 × 0.25rem = 1rem |
px-4 py-2 | padding: 0.5rem 1rem | x = horizontal, y = vertical |
m-auto | margin: auto | Centering trick |
flex items-center justify-between | flexbox row, vertically centered, space-between | Common layout pattern |
grid grid-cols-3 gap-4 | display:grid; grid-template-columns: repeat(3, 1fr); gap: 1rem | |
text-sm | font-size: 0.875rem; line-height: 1.25rem | Typography scale |
font-semibold | font-weight: 600 | |
rounded-md | border-radius: 0.375rem | |
shadow-sm | Small 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.