Why This Needs a Decision at All
React has no opinion about CSS. You can import an ordinary stylesheet and it works, exactly as it would on a plain HTML page. So why does every React tutorial spend time on styling?
Because components are meant to be self-contained, and plain CSS is not. Every rule you write lands in one global namespace shared by the entire application. Write .card in ProductCard.css and .card in ProfileCard.css, and whichever loads second wins — everywhere, including in components neither file has heard of. On a small project you dodge this with careful naming. On a real one, with several people writing components, it becomes a steady source of bugs where changing one screen breaks another.
The second friction is dynamic styling. A progress bar's width, a chart segment's colour, a panel's height computed from data — these depend on props and cannot be written as a fixed rule in a stylesheet.
Everything below is an answer to those two problems: how to scope styles to a component, and how to vary them at runtime. Pick one approach per project and stick to it. The worst outcome is not choosing the wrong option; it is using four of them in one codebase.
/* Button.css — a perfectly ordinary stylesheet */
.button {
padding: 10px 20px;
border: 0;
border-radius: 8px;
cursor: pointer;
}
// Button.jsx — the import is enough; the build tool handles the rest
import './Button.css';
function Button({ children }) {
return <button className="button">{children}</button>;
}
// It works. It is also now a global rule: any element anywhere with
// className="button" picks it up, and any other file defining .button
// silently competes with it. - Remember the JSX spelling:
className, notclass. The CSS file itself is completely normal CSS — only the attribute in JSX changes.
CSS Modules: Scoping Without a Library
A CSS Module is a stylesheet whose file name ends in .module.css. Vite and most other build tools recognise that suffix and treat the file specially: they rename every class in it to something globally unique, and give you an object mapping your names to the generated ones.
So you import the file as an object and use styles.button instead of the literal string. In the built page the class becomes something like Button_button__x7f2q. Two components can both define .card and they will never collide, because they are no longer the same class by the time the browser sees them.
This costs you nothing — no library, no configuration, no new syntax to learn beyond the file name. The CSS inside is completely ordinary, including nesting, media queries, hover states, animations and everything else you already know. It is the default recommendation for a project that is not using a utility framework.
Two practical notes. Because class names become object keys, a name with a hyphen has to be accessed as styles['card-header'], which is why camelCase names are conventional in module files. And when you need a class that must stay global — a class applied by a third-party library, say — wrap it in :global(...).
/* Button.module.css */
.button {
padding: 10px 20px;
border: 0;
border-radius: 8px;
cursor: pointer;
font: inherit;
}
.primary { background: #1f6feb; color: #fff; }
.secondary { background: #e6e6e6; color: #222; }
.button:disabled { opacity: 0.6; cursor: not-allowed; }
@media (max-width: 480px) {
.button { width: 100%; }
}
// Button.jsx
import styles from './Button.module.css';
function Button({ variant = 'primary', disabled, children }) {
return (
<button
className={`${styles.button} ${styles[variant]}`}
disabled={disabled}
>
{children}
</button>
);
}
// styles is a plain object:
// { button: 'Button_button__x7f2q', primary: 'Button_primary__k91ab', ... } - Name the file
Component.module.css, beside the component import styles from './Component.module.css'- Use
className={styles.name}, not a string - Class names are rewritten at build time, so collisions are impossible
- Use camelCase class names so they are valid object properties
:global(.name)opts a class out of the renaming
- If a style refuses to apply, check whether you wrote
className="button"instead ofclassName={styles.button}. The literal string does not exist in the built CSS, so nothing happens and nothing warns you.
Inline Styles: Good for Values, Bad for Rules
The style prop takes an object rather than a string. Keys are camelCase versions of CSS properties, and React adds px to numeric values on properties that need a unit, leaving unitless ones such as opacity, zIndex, flex and lineHeight alone. Anything else — percentages, rem, colours — is a string.
Inline styles are the right tool for a value that is genuinely computed at runtime and cannot be known in advance: a progress bar's width from a percentage, a bar chart's height from data, an element positioned where the user dropped it. There is no sensible way to express those in a stylesheet.
They are the wrong tool for everything else, and it is worth being clear about the limits. An inline style cannot express a hover state, a focus ring, a media query, an animation keyframe, or a rule that depends on a parent. It also carries very high specificity, so a stylesheet rule cannot override it without !important — which is a fight you do not want.
The pattern that works well is a combination: a class for the design, an inline style for the one value that changes. That way the hover, focus and responsive behaviour live in CSS where they belong, and only the genuinely dynamic number is inline.
import styles from './ProgressBar.module.css';
function ProgressBar({ percent, colour = '#1f6feb' }) {
return (
<div
className={styles.track}
role="progressbar"
aria-valuenow={percent}
aria-valuemin={0}
aria-valuemax={100}
>
{/* class for the design, inline style for the computed value */}
<div
className={styles.fill}
style={{ width: `${percent}%`, backgroundColor: colour }}
/>
</div>
);
}
// Units: React adds px where a unit is needed
<div style={{ padding: 16, marginTop: 8 }} /> // 16px, 8px
<div style={{ opacity: 0.5, zIndex: 10, flex: 1 }} /> // left alone, correctly
<div style={{ width: '50%', fontSize: '1.25rem' }} /> // strings for other units
// Not possible inline — these need real CSS:
// :hover, :focus, ::before, @media, @keyframes - Avoid building a whole component's appearance out of inline styles. Beyond the missing features, a new style object is created on every render, and the styles cannot be cached by the browser the way a stylesheet can.
Conditional Classes Without the Mess
Most real styling is conditional: a nav link that is active, a button that is loading, a row that is selected, an input that failed validation. Since className takes any expression, this is ordinary string building — but it gets ugly quickly, and the ugliness is where bugs hide.
A template literal with one ternary is fine. Two or three, and you will notice you are managing spaces by hand, and that a false slipping into the string produces className="btn false". Building an array and joining it avoids both problems and stays readable.
Beyond that, most projects install clsx — a tiny utility that takes strings, arrays and objects, ignores anything falsy, and joins the rest. It is about a hundred lines of code and it is worth it the first time you write a component with four states.
One more approach worth knowing for themes: CSS custom properties. Define your colours as variables on a root element, override them under a dark class, and every component that uses var(--surface) follows automatically. The whole theme switch becomes one class on <html> or <body>, with no prop threading and no re-render of anything.
// Readable for one condition
<button className={`btn ${isActive ? 'btn-active' : ''}`} />
// Readable for several
const classes = ['btn', `btn-${variant}`];
if (isActive) classes.push('btn-active');
if (isLoading) classes.push('btn-loading');
<button className={classes.join(' ')} />
// With clsx (npm install clsx)
import clsx from 'clsx';
<button
className={clsx('btn', `btn-${variant}`, {
'btn-active': isActive,
'btn-loading': isLoading,
'btn-error': Boolean(error)
})}
/>
/* Theming with CSS variables — one class flips the whole app */
:root {
--surface: #ffffff;
--text: #16181d;
}
:root.dark {
--surface: #16181d;
--text: #f2f3f5;
}
.card {
background: var(--surface);
color: var(--text);
}
// In React: document.documentElement.classList.toggle('dark', isDark) - Watch for the falsy-value trap here too.
className={`btn ${isActive && 'active'}`}produces the literal textbtn falsewhenisActiveis false. A ternary with an empty string, orclsx, avoids it.
Tailwind, CSS-in-JS and How to Choose
Two other approaches dominate React projects, and you will meet both.
Tailwind CSS is a utility framework: instead of writing rules, you compose small single-purpose classes directly in your JSX — flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2. It looks cluttered at first and most people stop minding within a week. What you get is no naming, no separate file to keep in step, no dead CSS accumulating, and a design system of consistent spacing and colour built in. It pairs particularly well with React, since a repeated set of classes is just a component. The cost is verbose markup and a build-tool setup step.
CSS-in-JS libraries such as styled-components let you write real CSS inside JavaScript and get back a component. Styles are scoped automatically and can read props directly, which makes variant-heavy components elegant. The trade-off is a runtime dependency and, in some setups, a performance cost — which is why the trend in newer projects has moved towards CSS Modules and Tailwind.
For someone learning React and building projects for a portfolio or an internship, the honest recommendation is CSS Modules if you want to keep writing CSS, or Tailwind if you would rather not name things. Both are widely used, both are employable, and either one is better than mixing approaches.
- Plain CSS — fine for a tiny project; no scoping, so it does not scale
- CSS Modules — scoped, built in, ordinary CSS; the safe default
- Tailwind CSS — fast to write, consistent by design, verbose markup
- CSS-in-JS — styles that read props; a runtime dependency
- Inline styles — for computed values only, never for a whole design
- Component libraries — Material UI, Chakra, shadcn/ui, when you need finished components fast
// The same button, three ways
// CSS Modules
<button className={`${styles.button} ${styles.primary}`}>Save</button>
// Tailwind
<button className="rounded-lg bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Save
</button>
// styled-components
const Button = styled.button`
padding: 8px 16px;
border-radius: 8px;
background: ${props => (props.$primary ? '#1f6feb' : '#e6e6e6')};
`;
<Button $primary>Save</Button> - Whichever you choose, keep styles next to the component they belong to and delete them when the component goes. Orphaned CSS that nobody dares remove is the most common form of rot in a front-end codebase, and scoping is what makes it safe to delete.
