BuildWithMatija
  1. Home
  2. Blog
  3. React
  4. When to Use CSS Modules: A Practical Migration Guide

When to Use CSS Modules: A Practical Migration Guide

Clarify component ownership, avoid premature scoping, and migrate global CSS to CSS Modules with a practical decision…

8th September 2026·Updated on:23rd September 2026·MŽMatija Žiberna·
React
When to Use CSS Modules: A Practical Migration Guide

⚛️ Advanced React Development Guides

Comprehensive React guides covering hooks, performance optimization, and React 19 features. Includes code examples and prompts to boost your workflow.

No spam. Unsubscribe anytime.

📄View markdown version
0

Frequently Asked Questions

Comments

About the author

Matija Žiberna

Matija Žiberna

Full-stack developer, co-founder

AboutResume

Self-taught full-stack developer sharing lessons from building software and startups.

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

Contents

  • Global CSS can be legitimate
  • BEM naming already does useful work
  • Hidden contracts survive scoping
  • Ask what the override is trying to say
  • 1. It is actually a component variant
  • 2. It is contextual theming
  • 3. It is legitimate parent layout
  • 4. It is a legitimate one-off composition
  • 5. It is accidental specificity debt
  • 6. It is dead
  • Only after that does CSS Modules become really useful
  • What CSS Modules are actually good at
  • Local naming becomes possible
  • Internal refactoring becomes safer
  • Accidental collisions disappear
  • Ownership becomes visible in the filesystem
  • CSS Modules can still host bad architecture
  • What should remain global?
  • Why this works well for custom design systems
  • Skip the big-bang migration
  • Start with easy components
  • Highly coupled components need a different first step
  • The migration should reduce knowledge
  • The same logic applies to utility classes
  • A practical decision framework
  • 1. Does this component actually own these styles?
  • 2. Are other components reaching into its internals?
  • 3. Are those overrides real variants?
  • 4. Are they contextual theme differences?
  • 5. Are they parent layout concerns?
  • 6. Are they accidental or dead?
  • 7. Is the remaining CSS mostly internal?
  • The real benefit
  • The rule I would keep
On this page:
  • Global CSS can be legitimate
  • BEM naming already does useful work
  • Hidden contracts survive scoping
  • Ask what the override is trying to say
  • Only after that does CSS Modules become really useful
Build with Matija Logo

Build with Matija

Complex B2B websites, headless CMS platforms, AI workflows, and internal systems designed and built with Next.js and Payload CMS.

Services

  • B2B Website Development
  • CMS Architecture Review & Platform Blueprint
  • Next.js + Payload Advisory
  • AI Integration & Implementation

Resources

  • CMS Hub
  • B2B Website Strategy
  • E-commerce Hub
  • Blog
  • Case Studies
  • About Matija

Payload CMS

  • Payload CMS Developer
  • Payload CMS Migration
  • Payload CMS Demos
  • All Payload CMS Resources

Discuss your project

Planning a rebuild, migration, application, workflow change, or platform decision? Start with the business problem and the system behind it.

Book a discovery callContact me →
© 2026Build with Matija•Alle Rechte vorbehalten•Datenschutzerklärung•Nutzungsbedingungen
BuildWithMatija
Get In Touch

If you had asked me a couple of years ago about CSS Modules, my honest reaction would have been: "Why would anyone choose CSS Modules today?"

Like many React developers, Tailwind CSS and utility-first styling had become my instinctive default for almost every project. Utility classes make styling fast, co-located, and immune to dead-code accumulation.

Then I took over a client frontend with almost 20,000 lines of legacy global CSS.

The client's designer had already begun cleaning up components and establishing consistent visual rules, but the sheer volume of cascading global overrides made touch-ups hazardous. Moving the entire codebase to Tailwind was out of the question—it would have required rewriting every markup file and halting all feature work. We needed a way to break up the stylesheet cleanly and give components isolated boundaries without triggering a risky rewrite.

That brought CSS Modules back into focus—and reminded me of an essential rule:

CSS Modules enforce existing ownership boundaries. Defining those boundaries is separate work.

And that distinction matters.

Global CSS can be legitimate

Large global stylesheets tend to trigger an immediate architectural reaction.

They look risky.

They can be hard to navigate.

They can create specificity problems.

They can make it difficult to know which styles belong to which component.

All of that can be true without meaning the architecture is broken.

A global stylesheet can still contain:

  • design tokens
  • reset rules
  • typography foundations
  • document-level layout
  • accessibility helpers
  • theme variables
  • third-party overrides
  • deliberate application-wide contracts

Those things are genuinely global. They belong there even if the stylesheet stays large.

The more useful question is:

Which styles are actually global, and which styles belong to a component but currently live globally?

That is a very different problem.

BEM naming already does useful work

The frontend I was reviewing used a lot of BEM-style naming.

Something like:

css
.cp-product-card {}
.cp-product-card__title {}
.cp-product-card__media {}
.cp-product-card--compact {}

BEM does useful work here. It tries to establish ownership through naming.

The problem appears when other parts of the application start reaching into those internals.

For example:

css
.hero .cp-product-card__title {
  color: white;
}

.recipe-feature .cp-product-card__media {
  height: 140px;
}

The naming stays structured even as the ownership boundary erodes.

The parent feature knows too much about how ProductCard is implemented internally. That cross-boundary knowledge is the architectural smell, regardless of whether the class is global.

Hidden contracts survive scoping

This is where CSS Modules can give you a false sense of progress.

Imagine we take:

css
.cp-product-card__title {}
.cp-product-card__media {}

and move it into:

text
ProductCard.module.css

Great. Now the internal selectors are locally scoped. Here's what happens to this:

css
.hero .cp-product-card__title {
  color: white;
}

That selector was expressing a real requirement. The hero wanted a different version of the product card. Once the class becomes locally scoped, the parent can no longer reach into it directly.

Scoping the class removes the mechanism that expressed the requirement. The requirement itself stays.

That is why migrating a highly coupled component into CSS Modules too early can actually make the situation worse.

You need to understand the dependency first.

Ask what the override is trying to say

Before moving a component into a CSS Module, I would classify its external styling dependencies.

Most overrides I encounter fall into a handful of categories.

1. It is actually a component variant

For example:

css
.hero .product-card__title {
  color: white;
}

may really mean:

tsx
<ProductCard tone="inverse" />

The component has a legitimate alternate presentation. The API simply never exposed it. That's a gap in the component's contract.

2. It is contextual theming

Sometimes the difference applies to everything inside a section.

For example:

css
.dark-section .button {}
.dark-section .card {}
.dark-section .heading {}

That may be better expressed through semantic variables:

css
.darkSection {
  --foreground: white;
  --surface: #111;
  --muted-foreground: #bbb;
}

Now components respond to context, and parents no longer need to manually restyle each one. This is a theme context, distinct from a component variant.

3. It is legitimate parent layout

Consider:

css
.carousel .product-card {
  width: 280px;
}

That may be completely fine. The parent owns layout. It is reasonable for a grid, carousel or section to decide:

  • width
  • placement
  • gap
  • alignment
  • grid span

Turning every parent rule into a prop, like:

tsx
<ProductCard width="280px" />

moves layout responsibility into the wrong place.

4. It is a legitimate one-off composition

Not every unusual design needs to become a reusable variant. If something appears once and is genuinely specific to one feature, a local wrapper or composition may be cleaner than expanding a shared component API.

Otherwise you end up with things like:

tsx
<ProductCard
  variant="homepage-summer-promo-left-column"
/>

That's a prop list built from accumulated one-off exceptions.

5. It is accidental specificity debt

Sometimes an override exists because an earlier selector was too strong.

For example:

css
.page .section .product-card .title {}

followed by:

css
.homepage .hero .product-card .title {}

At that point, what you're looking at is CSS escalation: each new selector added just to out-rank the last one.

The right answer may simply be to simplify the selectors.

6. It is dead

This is surprisingly common.

Large global stylesheets often preserve rules for:

  • removed components
  • old layouts
  • abandoned experiments
  • renamed classes
  • obsolete responsive states

Do not formalize those. Delete them.

Only after that does CSS Modules become really useful

Once you understand what a component actually owns, CSS Modules become valuable.

Now you can safely move:

text
ProductCard.tsx
ProductCard.module.css

and keep internal styles local:

css
.root {}

.title {}

.media {}

.actions {}

The component can own its intentional states:

css
.inverse {}

.compact {}

.featured {}

And the public API can expose them explicitly:

tsx
<ProductCard
  tone="inverse"
  density="compact"
/>

At that point the module is reinforcing a boundary that already makes sense.

That is where CSS Modules shine.

What CSS Modules are actually good at

The biggest benefit is ownership. Everything else, including shorter CSS, follows from that.

A component stylesheet becomes much easier to reason about when you know:

These styles belong to this component.

You get several useful properties.

Local naming becomes possible

You no longer need to globally protect every class name. A prefixed list like this:

css
.cp-product-card__title {}
.cp-product-card__media {}
.cp-product-card__actions {}

can simplify to this:

css
.title {}
.media {}
.actions {}

The module provides the namespace.

Internal refactoring becomes safer

If .title is truly private to ProductCard, you can change it without worrying that six unrelated pages are depending on it.

That makes component refactoring much less dangerous.

Accidental collisions disappear

Two unrelated components can both have:

css
.title {}

without interacting.

Ownership becomes visible in the filesystem

This is underrated.

When you see:

text
ProductCard/
  ProductCard.tsx
  ProductCard.module.css

the architecture communicates itself. The component's styling location is immediately obvious, without searching through thousands of global lines.

CSS Modules can still host bad architecture

You can absolutely build badly coupled components with CSS Modules.

For example, a component might accept:

tsx
<ProductCard
  titleClassName={styles.specialTitle}
  mediaClassName={styles.smallMedia}
  actionClassName={styles.hiddenActions}
/>

The scoping is real, and the parent is still manipulating the child's internals beneath it. Global selectors have simply become prop-based selector injection, carrying the same coupling forward.

The real question is still:

Who should own this decision?

If the child owns it, expose a semantic API. If the parent owns it, let the parent control layout. If it is context, use context. If it is truly one-off, compose locally.

Design-system thinking has to happen regardless of scoping.

What should remain global?

Even in a codebase that uses CSS Modules heavily, I would expect some global CSS to remain.

Usually things like:

text
Design tokens
Theme variables
Font declarations
CSS reset
html/body defaults
Document typography foundations
Global focus treatment
Accessibility helpers
Portal/body coordination
Third-party library overrides
Rare application-wide contracts

The goal: global CSS holds only things that are genuinely global, however many lines that leaves.

That's a far more useful target than counting bytes.

Why this works well for custom design systems

CSS Modules are particularly useful in projects with a strong custom visual identity.

In those projects you often have branded components like:

text
ProductCard
HealthGoalCard
StoryCard
HeroProductCard
SupplementQuiz
ResourceCard

Their visual behaviour is specific to the product. A local stylesheet next to the component gives that component a clear ownership boundary. Meanwhile, the interaction primitives underneath may come from another layer.

For example:

text
Product feature
    ↓
Brand Drawer
    ↓
Base UI behaviour

while:

text
Drawer.tsx
Drawer.module.css

owns the brand-specific visuals.

That produces a useful separation:

text
Interaction behaviour → primitive layer
Visual styling → local component
Global design language → tokens/theme
Layout → parent

Each concern has somewhere obvious to live.

Skip the big-bang migration

This is probably the most practical lesson.

A project titled:

Convert globals.css to CSS Modules

sets a mechanical goal. It says nothing about whether the architecture improves.

Migrating incrementally, inside the path of real feature work, keeps the goal tied to the architecture. For example:

text
Component enters production work
        ↓
inspect external dependencies
        ↓
classify them
        ↓
formalize real component contracts
        ↓
remove unnecessary coupling
        ↓
move owned styles into CSS Module
        ↓
validate visual parity

That's slower per component, and safer for the project as a whole. Because you're doing it in the path of real implementation, you're spending time on components that actually matter.

Start with easy components

When introducing CSS Modules into an existing application, start with something isolated.

For example, a marketing band that:

  • owns its entire visual structure
  • has no external consumers styling its internals
  • has a small prop surface
  • does not depend on complicated responsive overrides

Move that first. Establish:

  • file convention
  • import pattern
  • naming style
  • testing expectations
  • Storybook pattern
  • token usage

Then move to slightly more complicated components. Only later tackle the shared components with significant external dependencies.

This gives the team a working pattern before the difficult cases appear.

Highly coupled components need a different first step

Suppose ProductCard is used everywhere.

Homepage.

Search.

Saved products.

Recommendations.

Recipes.

Product listings.

And each one modifies it slightly differently. That's a poor candidate for a first CSS Module migration.

First build a map of those differences. Maybe you discover that the dozens of overrides actually reduce to:

text
tone
density
metadata visibility
action presentation
layout context

Now you can design a stable API. Then move the styles. This is much better than translating every existing selector directly into another styling system.

The migration should reduce knowledge

One useful way to judge whether the refactor is working is to ask:

How much does the parent need to know about the child?

Before:

css
.hero .product-card__title {}
.hero .product-card__media {}
.hero .product-card__actions {}

The parent knows three implementation details.

After:

tsx
<ProductCard tone="inverse" density="compact" />

The parent knows two supported concepts. That is an improvement.

The goal here is less cross-component knowledge.

The same logic applies to utility classes

The same principle applies when teams respond to global CSS by moving everything into Tailwind utilities.

Utility classes can be excellent, and a mechanical migration from:

css
.product-card__title {
  ...
}

to a long list of classes inside JSX still doesn't automatically improve ownership.

You can still have:

  • hidden component states
  • unclear layout responsibility
  • duplicated patterns
  • parent-child coupling
  • inconsistent APIs

Changing the syntax leaves the architectural question exactly where it was:

Where does this styling decision belong?

Once that's answered, the choice of CSS technology follows easily.

A practical decision framework

When I consider moving a component into CSS Modules, I would ask:

1. Does this component actually own these styles?

If not, figure out who does.

2. Are other components reaching into its internals?

If yes, classify why before scoping them away.

3. Are those overrides real variants?

If yes, create explicit component contracts.

4. Are they contextual theme differences?

Use semantic variables where appropriate.

5. Are they parent layout concerns?

Keep them in the parent.

6. Are they accidental or dead?

Remove them.

7. Is the remaining CSS mostly internal?

Now CSS Modules are probably a good fit.

The real benefit

CSS Modules matter most for the architectural statement they make:

This styling belongs to this component.

Generating unique class names is a useful side effect. Once component contracts are explicit, that statement becomes a genuinely powerful constraint.

The mistake is applying that constraint before understanding the system.

So when I inherit a large global stylesheet now, the first question is:

What does this CSS tell us about the actual ownership boundaries in the frontend?

Speed comes later. Once those boundaries are understood, the migration gets both easier and more valuable.

The rule I would keep

The simplest version is:

Define ownership first. Scope it second.

CSS Modules do their real work once boundaries are already deliberate. The scoping just makes them permanent.

Comments