The Problem With Global CSS at Scale
Global CSS works well when a project is small and the team is one person. You write a .card class, apply it to a handful of components, and everything is manageable. Six months later the project has 40 components, three developers, and a designer who wants to change the border radius on cards across the entire application.
You update the class. Then you discover that .card was also being overridden in five different component-level stylesheets. Two of those overrides were intentional. Three were not. The change that should have taken two minutes takes two hours — and you still are not certain everything is correct.
This is the core problem with global CSS at scale: shared styles create invisible dependencies. Any change to a shared class affects every element using it, and there is no reliable way to know what that includes without searching the entire codebase.
What Tailwind Does Differently
Tailwind CSS is a utility-first framework. Instead of writing named classes that group multiple style declarations, you apply single-purpose utility classes directly to elements in your markup.
/* Global CSS approach */
.card {
padding: 1.5rem;
background-color: white;
border-radius: 0.75rem;
border: 1px solid #e5e7eb;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* Tailwind approach */
<div className="p-6 bg-white rounded-xl border border-gray-200 shadow-sm">
At first glance the Tailwind version looks verbose. In practice it solves the invisible dependency problem entirely — the styles for an element are exactly what you see on the element. There is no stylesheet to open, no inheritance to trace, no cascade to debug.
Reuse Without Abstraction Overhead
The objection developers raise most often against Tailwind is repetition. If the same card pattern appears in 20 components, are you copying those class strings 20 times?
In a React or Next.js project, the answer is no — you extract the pattern into a component once:
// components/ui/Card.tsx
interface CardProps {
children: React.ReactNode;
className?: string;
}
export function Card({ children, className = '' }: CardProps) {
return (
<div className={`p-6 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm ${className}`}>
{children}
</div>
);
}
// Usage — consistent styles, single source of truth
<Card>Project summary content</Card>
<Card className="hover:shadow-md transition-shadow">Interactive card</Card>
The Card component is the single source of truth for card styles. Changing the border radius means updating one component. Every usage updates automatically — no find-and-replace, no missed instances.
This is the correct level of abstraction. You abstract at the component boundary, not at the CSS class boundary. The component represents a UI concept. The utilities describe how it looks. They serve different purposes.
Design Tokens in tailwind.config.ts — One Change, Whole Codebase
The most powerful maintenance feature in Tailwind is the config file. Design tokens — your brand colours, spacing scale, typography, border radii — live in tailwind.config.ts. Every utility class that references those tokens updates when the token changes.
// tailwind.config.ts
import type { Config } from 'tailwindcss';
const config: Config = {
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
900: '#1e3a8a',
},
brand: {
DEFAULT: '#2563eb',
dark: '#1d4ed8',
}
},
borderRadius: {
card: '0.75rem',
btn: '0.5rem',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
}
},
},
};
export default config;
With this config in place:
bg-primary-600— applies your brand blue as a backgroundtext-primary-600— applies the same colour as texthover:bg-primary-700— applies the darker shade on hoverrounded-card— applies your card border radius
If the designer changes the primary brand colour, you update one hex value in the config. Every button, badge, link, border, and background using primary-600 across the entire codebase reflects the change immediately — without touching a single component file.
In a global CSS system, the equivalent change requires finding every place the colour was hardcoded or referenced as a CSS variable, verifying nothing was overridden locally, and re-testing the entire UI. The config-based approach removes that work entirely.
Co-location Makes Debugging Faster
When a bug is reported on a specific component — wrong padding on mobile, colour not matching the design — you open one file. The markup and the styles are in the same place. You can see exactly what is applied, read it top to bottom, and make the fix without context switching.
// Everything you need to understand this component's appearance is right here
export function AlertBanner({ message, type }: AlertBannerProps) {
const styles = {
error: 'bg-red-50 border-red-200 text-red-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
success: 'bg-green-50 border-green-200 text-green-800',
};
return (
<div className={`px-4 py-3 rounded-lg border text-sm font-medium ${styles[type]}`}>
{message}
</div>
);
}
With global CSS, the same component might have styles split across a base stylesheet, a component stylesheet, and possibly an override in a parent component. Understanding what the element actually looks like requires reading all three files and understanding the cascade between them.
No Specificity Wars
CSS specificity is one of the most common sources of bugs in large stylesheets. A style is not applying and the reason is that a more specific selector elsewhere is winning. You add !important as a quick fix, which makes the next specificity conflict harder to resolve.
Tailwind utility classes all have the same specificity — a single class. There are no compound selectors, no ID selectors, no deeply nested rules. The class you put on the element is the class that applies. The cascade is not a variable you have to account for.
Dark Mode Without a Separate Stylesheet
Dark mode in a global CSS setup typically means a parallel set of overrides — either a [data-theme="dark"] selector block or a separate stylesheet loaded conditionally. Tailwind handles it with a prefix:
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Page Title
</h1>
<p className="text-gray-600 dark:text-gray-300">
Content that is readable in both modes.
</p>
</div>
The light and dark styles for each element are co-located. You can see both states on a single line. Testing dark mode means toggling a class on the root element — the entire component tree responds without additional configuration.
Responsive Design at the Element Level
Tailwind's responsive prefixes (sm:, md:, lg:, xl:) follow the same co-location principle. Instead of writing media queries in a stylesheet, responsive behaviour is expressed directly on the element:
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{posts.map(post => (
<article
key={post.id}
className="p-4 md:p-6 rounded-xl border border-gray-200"
>
<h2 className="text-lg md:text-xl font-bold">{post.title}</h2>
</article>
))}
</div>
One file. One glance. You can see the mobile layout, the tablet layout, and the desktop layout simultaneously. No jumping between HTML and a media query block in a separate file.
Bundle Size — Only What You Use
A common concern is that Tailwind generates a large CSS file. In production this is not the case. Tailwind scans your source files, identifies every utility class actually used, and generates a stylesheet containing only those classes. A typical production Next.js build with Tailwind produces 5–15KB of CSS — smaller than most hand-crafted global stylesheets for a project of the same size.
// tailwind.config.ts — content paths tell Tailwind what to scan
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./lib/**/*.{js,ts,jsx,tsx}',
],
When Global CSS Still Has a Place
Tailwind does not eliminate global CSS entirely. There are cases where a global stylesheet makes sense:
- Base resets and typography defaults —
@tailwind basehandles most of this, but custom prose styles for blog content often warrant a global rule - Third-party component overrides — when you cannot add classes to the element directly
- CSS animations defined with
@keyframes
These are narrow, specific cases. The general rule is: if you can express it with utilities, do so. Reserve global CSS for what utilities genuinely cannot handle.
The Maintenance Argument in Summary
The reason Tailwind has become the default choice for production React and Next.js projects is not aesthetics. It is the maintenance model:
- Design tokens changed in one place propagate to the entire codebase
- Component styles are co-located — one file to open, one file to fix
- No shared stylesheets means no invisible dependencies between components
- No specificity hierarchy means no cascade bugs to debug
- Dark mode and responsive styles are expressed on the element, not in parallel rule sets
Global CSS is not wrong. It is the right tool for documents and small sites. For component-based applications that will be maintained, extended, and handed over to other developers, Tailwind's utility-first model is a fundamentally better fit.
For a related look at how reusable component architecture works in Next.js projects, see the post on building reusable components in Next.js. If you are building a React or Next.js application and want the frontend architecture set up correctly, see the React and Next.js development services page or get in touch.