The styling debate is usually argued in the first month of a project and paid for in the third year. A small team picks an approach, everything renders beautifully, and the bill arrives later: the styled components build that added seconds to hydration, or the utility soup that made every class name on the page a twenty word wall with no discoverable conventions. Both bills are real, and both are avoidable, because the two approaches optimize for different halves of the styling problem and both halves exist in every large frontend. This article takes the argument to the place where it is actually decided, a production codebase after several years, and examines what each approach costs and returns there, from runtime and build characteristics to debugging, refactoring and the onboarding of the ninth engineer onto the code.
The two mental models in one paragraph each
CSS in JS puts styles next to the component that renders them. A styled button declares its own appearance, scoped by build generated class names, colocated with the markup it styles:
const Button = styled.button`
padding: 0.5rem 1rem;
border-radius: 6px;
background: var(--color-primary);
`
Tailwind puts a fixed vocabulary of utility classes in the markup and keeps the vocabulary consistent by construction:
<button class="px-4 py-2 rounded-md bg-blue-600 text-white">
Save
</button>
The first model optimizes for encapsulation and for the stylesheet as code, with variables, functions and component level composition. The second optimizes for consistency and for the absence of invention, because the developer who can only combine existing utilities cannot drift from the design system, and the one who writes arbitrary CSS always can. Large codebases feel both forces simultaneously, which is why the argument never fully resolves and why the honest comparison is about cost profiles rather than winners.
The runtime question that CSS in JS had to answer
The original generation of CSS in JS libraries ran in the browser: they parsed style templates, generated class names, and injected style tags during hydration and on every dynamic change. That work had a visible price at scale, tens of milliseconds of script evaluation on the main thread before the first meaningful paint on lower end devices, plus a continuous reconciliation cost when styles depended on props. The ecosystem's answer was extraction, moving the generation to build time, and the modern configurations of the major libraries, compiled, zero runtime or statically extracted modes, produce ordinary CSS with ordinary performance. The caveat is that extraction only covers the static shapes; the styles that change with props at runtime still generate on the client, and a codebase that leaned on dynamic styling keeps a slice of that cost no matter how the static majority is compiled away.
The operational lesson from the years of migration posts is that the runtime cost was never the primary pain. Teams that left CSS in JS cited three things more often: the debugging experience of build generated class names, the difficulty of reasoning about specificity between styled wrappers, and the constant presence of a styling abstraction in every component review. The runtime was measurable and fixable, while the cognitive costs were the ones that accumulated quietly.
The utility vocabulary and the discipline that keeps it readable
Tailwind's own failure mode is well documented by its critics: class lists grow until the markup is unreadable, the same twenty utilities are repeated across a hundred components, and the developer who needs one unusual value reaches for the escape hatch. The framework's answer to itself is the extraction pattern, and the teams that use Tailwind successfully treat the extract as the norm, not the exception:
const button = "px-4 py-2 rounded-md bg-blue-600 text-white font-medium"
function Button({ children }) {
return <button class={button}>{children}</button>
}
The repetition problem and the readability problem are both solved by moving class strings into named constants, components or helper functions, which is ordinary refactoring discipline applied one level below where styled components put it. The vocabulary itself is the enforcement of the design system: the spacing scale, the color palette and the type sizes exist as utilities with compile time existence, and the purge or content scanning build step guarantees that the shipped stylesheet contains only what the markup references, typically a few tens of kilobytes for a large application.
The debugging experience is the mirror of CSS in JS: the class names are human readable and identical between the source and the devtools, there is no generated indirection, and the cascade is flat because utilities do not nest. The cost that replaces it is convention, because nothing in the framework forces extraction, and the team that does not enforce it produces the wall of classes the critics correctly describe.
CSS Modules as the middle road nobody regrets
The debate has a third participant that large codebases keep arriving at independently, because it combines the two models' strengths without inheriting the argument. CSS Modules scopes ordinary CSS files by class, hashing names at build time, so styles sit next to the component in a plain stylesheet with no runtime and no utility vocabulary:
/* Button.module.css */
.button { padding: 0.5rem 1rem; border-radius: 6px; background: var(--color-primary); }
import styles from './Button.module.css'
<button class={styles.button}>Save</button>
The developer writes standard CSS, the class names collide nowhere, and the devtools show both the source name and the scope hash. The approach inherits plain CSS's tooling: linters, formatters, and every future engineer's existing knowledge. It gives up CSS in JS's programmatic composition and Tailwind's enforced vocabulary, which is precisely why it pairs with a token layer and a small set of conventions rather than replacing either framework's discipline. Teams migrating away from runtime CSS in JS frequently land here as the first stop, discovering that they wanted scoping more than they wanted JavaScript in their stylesheets.
Server side rendering and the first paint budget
The styling approach meets the server at the critical rendering path, and the two models behave differently there. Plain CSS and Tailwind ship a static stylesheet that the HTML link tag references, so the server sends the same bytes for every visitor and the browser starts painting on style download. Server side rendering with CSS in JS requires the styles to be extracted during the render pass and inlined or emitted into the response, which every mature library supports but which adds moving parts: the render must serialize the exact styles that the rendered tree generates, and a hydration that regenerates different classes produces a flash of unstyled content or a mismatch warning. The compiled and zero runtime variants eliminate the problem by being plain CSS at build time, which is the single strongest technical argument for those configurations in a server rendered product.
The measurement that settles it for a given codebase is the field data: first contentful paint on real devices, and the size of the shipped CSS. A static stylesheet of a few tens of kilobytes that downloads once and caches forever is the budget both approaches can hit, and the runtime variants are the ones that need a justification written next to their numbers.
Debugging and refactoring in the third year
The third year of a large codebase is where the approaches show their real differences. The CSS in JS codebase debugs by tracing generated class names back to source, which the tooling supports with sourcemap labels, and by navigating a specificity landscape where styled wrappers compose through cascade layers that are visible only at runtime. Its refactoring strength is the component boundary: renaming a styled component and updating its references is an ordinary type safe rename, and a style change cannot leak past the component that owns it.
The Tailwind codebase debugs with the class list in the markup as the single source of truth, and its refactoring strength is the design token shift: when the brand changes the primary color, the token changes in one configuration file and every usage follows, including the ones no one remembered. Its weakness is the reverse of encapsulation: a class string copied into three components is three definitions, and the team discipline of extracting shared patterns is what keeps that number at one. A theme redesign exercises both weaknesses differently, with the CSS in JS codebase requiring mechanical renames across styled definitions and the Tailwind codebase requiring sweeps for the utility patterns that encode the old visual language.
Migration between them without the rewrite
The argument gets practical when a team inherits one approach and wants the other, and the rewrite instinct deserves the same skepticism it received in the Vue article. The migration that works in both directions is the seam driven one. From CSS in JS to Tailwind: new screens adopt utilities by default, and the styled components convert opportunistically when a feature touches them, with the design tokens ported to the Tailwind configuration first so that the converted screens inherit the same palette and scale. From Tailwind to CSS in JS or to any colocated model: the extraction seams are the entry point, because every extracted component is already a unit with a defined appearance, and the class strings lift into styles with the markup untouched.
In both directions the team decision that matters is not the library, it is the token layer: a design token file, colors, spacing, type, shadows, defined once and referenced everywhere, is portable across both approaches and across future ones. The teams that fought the framework wars usually discover the token layer was the actual product, and the utility vocabulary and the styled component were both just renderers of it.
The decision rules for a large codebase
The decision that survives several years is written down and revisited rarely, and the working set is short:
- Fix the token layer first, one configuration or one variables file, and let the styling approach consume it;
- Pick one default for new code, the one the team knows, and write the rule down once instead of per pull request;
- If the default is CSS in JS, configure static extraction and keep dynamic, prop driven styling to the components that need it;
- If the default is Tailwind, make extraction of shared patterns a reviewed convention, not a cleanup activity;
- Migrate through seams, new screens and touched components, never through a freeze and a rewrite;
- Measure the styling cost where it is visible, shipped CSS bytes and first paint in the field, not in the debate.
The two approaches are not versions of the same idea, they are different bets about where styling knowledge should live, and both bets pay off when the discipline behind them matches their design. The codebase that treats either one as a set of habits to maintain, rather than an identity to defend, gets the benefits and is free to notice when the next approach arrives.
The token layer written down
The token layer this article keeps deferring to is one file, and one example fixes it better than a definition. The design decisions that outlive any styling library, the palette, the spacing scale and the type ramp, declared once and consumed everywhere:
:root {
--color-bg: #ffffff;
--color-primary: #2563eb;
--color-text: #111827;
--space-1: 0.25rem; --space-2: 0.5rem; --space-4: 1rem;
--font-size-body: 1rem; --font-size-title: 1.5rem;
}
A Tailwind configuration references the same values in its theme section, a CSS Modules file consumes them through var(), and a styled component interpolates them without knowing which system rendered its neighbors. The file is the portability guarantee that makes the styling choice reversible, because any future approach, the next framework, the next design system, the designer's next tool, imports the same names, and the migration of the previous sections becomes a change of consumers rather than a change of truth.