Lesson 50 of 80
CSS Modules, CSS-in-JS, and utility classes all solve 'how do I scope styles to a component' differently — and the differences show up in your bundle, not just your syntax.
14 min read
Every option explains itself — why the right one is right, and why each wrong one is wrong. Miss one and it comes back later on a spaced-repetition schedule.
This topic is about judgment and practical tradeoffs more than a single right answer — a mock interview is the better way to test yourself on it.
Be interviewed on this →Hints without spoilers, and a plain explanation of why a wrong answer was wrong — answered only from this lesson. Free account, no card.
Sign up freeWhat interviewers actually ask about this, and what a strong answer has to cover.
A strong answer covers
Likely follow-ups
React doesn't have an opinion on how you write CSS. That's freedom, and it's also why every team argues about it. Three approaches dominate in practice, and they differ on one real axis: when does the browser find out what your styles are — at build time, or at runtime, in the user's browser?
CSS Modules are plain .css files where class names get hashed to be unique per component (.button becomes .Button_button__a1b2c). You write real CSS, a build step does the scoping, and by the time the browser sees anything it's just a stylesheet — zero runtime cost.
CSS-in-JS (styled-components, Emotion) lets you write CSS inside your JavaScript, often with access to props and theme values. The classic versions generate and inject <style> tags while your app runs, which is powerful (a button's color can depend on a prop) but isn't free — the browser is doing style computation your build tool could have done instead.
Utility classes (Tailwind) flip the model entirely: instead of naming things, you compose small single-purpose classes directly in JSX. No naming problem, no runtime — but your markup gets visually noisy and reads like an unfamiliar dialect until you're used to it.
/* Button.module.css */
.button {
padding: 8px 16px;
border-radius: 6px;
background: var(--brand-color, #3b82f6);
}
// CSS Modules
import styles from "./Button.module.css";
function Button({ children }) {
return <button className={styles.button}>{children}</button>;
}
// CSS-in-JS (styled-components)
import styled from "styled-components";
const StyledButton = styled.button`
padding: 8px 16px;
border-radius: 6px;
background: ${(props) => props.$variant === "danger" ? "#ef4444" : "#3b82f6"};
`;
function Button({ children, variant }) {
return <StyledButton $variant={variant}>{children}</StyledButton>;
}
// Utility classes (Tailwind)
function Button({ children, variant }) {
const bg = variant === "danger" ? "bg-red-500" : "bg-blue-500";
return <button className={`px-4 py-2 rounded-md ${bg}`}>{children}</button>;
}
Notice what actually changes between them: CSS Modules needs a separate file and produces zero runtime work. Styled-components keeps styling and logic in one file and can react to props directly, at the cost of a runtime style-injection step (modern versions ship a build-time "zero-runtime" mode specifically to claw this cost back). Tailwind needs no separate file and no runtime, but the "prop-driven style" problem gets solved with a plain JS ternary picking between pre-defined classes, not by generating new CSS on the fly.
body) still cascade into your "scoped" component. Scoping avoids name collisions; it doesn't build a wall.`text-${color}-500`) and the scanner can miss them — the style silently doesn't ship.The real question underneath all three approaches is the same one that shows up anywhere a compiler decides what work happens before shipping versus after: can this be resolved once, at build time, or does it genuinely depend on something only known in the browser? The cascade and specificity rules of CSS itself don't change across any of these — you're always still subject to them. What changes is only when the class-to-rule mapping gets decided.