09 — Styling React — Tailwind, CSS Modules, and the CSS-in-JS Spectrum
A buffet of equally-valid styling options with no way to choose was my React-styling experience, until the tradeoff line appeared. The framing that organized it: every styling approach is trading off the same three things — scoping (do my class names collide?), runtime cost (does styling ship JavaScript to the browser?), and authoring ergonomics (how pleasant is it to write?). [1] Tailwind, CSS Modules, and the CSS-in-JS family each land at a different point on that line, and picking one is mostly a matter of which tradeoffs I'm willing to accept.
The problem each approach solves
Plain CSS in a React app has two failure modes the rest of the ecosystem is reacting to:
- Global namespace. Class names live in one global scope, so .button in two files collide. Specificity wars and !important cascades follow.
- Dead CSS. A stylesheet ships everything it defines, whether the components using those classes still exist or not.
Every modern styling solution is some combination of "scope the names automatically" and "only ship the styles actually used." The differences are in how, and at what cost.
CSS Modules: built-in scoping
CSS Modules are CSS files where every class name is scoped locally by default [2]. The build tool generates a unique hashed name for each class, so .button in Button.module.css can't collide with .button anywhere else. I write normal CSS; the tool handles the isolation.
/* Button.module.css */
.button { background: indigo; padding: 8px 16px; }import styles from './Button.module.css';
<button className={styles.button}>Save</button>CSS Modules are the lowest-friction step up from plain CSS — no new way of thinking, no runtime cost, supported out of the box by Vite and Next.js. The tradeoff is that I'm still writing CSS, with its full feature set and its full verbosity, and dynamic styling (changing styles based on state) means conditional class strings.
Tailwind: atomic utility classes
Tailwind takes the opposite philosophical bet [3]. Instead of writing custom CSS, I compose atomic utility classes directly in the markup — flex, pt-4, text-center, rotate-90. Each utility does one thing; I string them together to build any design.
<button className="bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg">
Save
</button>The payoff is speed: no context-switching between JSX and CSS, no naming things, and the generated stylesheet is tiny because Tailwind tree-shakes to only the utilities I actually use. The cost is long className strings and a learning curve for the utility vocabulary. The objection "but the markup is ugly" is real but, for me, fades with use — the productivity gain is large enough that I've stopped minding it.
Tailwind is the dominant choice for new React work, and it's what I default to. The roadmap lists it alongside the other approaches [3], and the wider frontend notes cover it in more depth.
CSS-in-JS: styling as JavaScript, with a runtime question
CSS-in-JS libraries (styled-components, emotion) let me write actual CSS inside my components, scoping each style to the component and using JavaScript values for dynamic styling. The ergonomics are excellent — props flow naturally into styles, themes are just objects.
The historical cost was a runtime: the library executed JavaScript in the browser to generate and inject the styles, which added bundle size and a small runtime overhead. That's the tradeoff Panda CSS is explicitly reacting to.
Panda CSS: CSS-in-JS without the runtime
Panda CSS is the modern answer to the runtime question [4]. It gives me the authoring ergonomics of CSS-in-JS (typed, theme-aware, multi-variant — what Tailwind calls "class variants"), but it generates the styles at build time, so there's no runtime cost. It's also React Server Components compatible, which runtime CSS-in-JS libraries generally aren't.
const button = css({ bg: 'indigo.600', px: '4', py: '2', borderRadius: 'lg' });
<button className={button}>Save</button>Panda is the choice when I want CSS-in-JS ergonomics (typed themes, variant-based APIs) in a project that can't afford a runtime or needs RSC. For most of my work Tailwind covers the same ground, so I reach for Panda specifically when typed theming and variants are the deciding factor.
How I use this
My default is Tailwind for new projects — the productivity and the tiny generated stylesheet outweigh the verbose className complaint, and it's the closest thing to a community standard right now. CSS Modules are my fallback when a project needs hand-written CSS (a third-party theme integration, a legacy codebase) without a new dependency. I reach for Panda CSS specifically when typed theming and variant APIs matter and the project is on a modern stack with RSC. What I avoid: runtime CSS-in-JS in any new project, because the runtime cost is no longer necessary and it breaks Server Components. The tradeoff line is the same in every case; I just pick where on it I want to land.
References
[1] R. Wieruch, "Ways to CSS style a React application," robinwieruch.de, 2023. [Online]. Available: https://www.robinwieruch.de/react-css-styling/
[2] css-modules, "CSS Modules," GitHub, 2024. [Online]. Available: https://github.com/css-modules/css-modules
[3] Tailwind Labs, "Tailwind CSS — rapidly build modern websites," tailwindcss.com, 2024. [Online]. Available: https://tailwindcss.com
[4] Panda CSS, "Panda CSS — build-time CSS-in-JS," panda-css.com, 2024. [Online]. Available: https://panda-css.com
[5] Infinum, "Panda CSS — CSS-in-JS without runtime overhead," infinum.com blog, 2023. [Online]. Available: https://infinum.com/blog/panda-css-css-in-js-without-runtime-overhead/
Knowledge check · Question 1 of 5
The three things every styling approach trades off are:
Comments
Leave a Comment
You must be signed in to comment
0 Comments
No comments yet. Be the first to comment!