23 min read

Migration from CSS Modules to Tailwind CSS for component library

Table of Contents

Why We Migrated

We didn’t move to Tailwind just because it was trendy.

We moved because our CSS workflow was broken.

Let me explain.

The Pain Points with CSS Modules

CSS Modules were our go-to for years.

Scoped styles, no naming collisions, and predictable output.

But as our codebase grew, so did the problems.

Here’s what started to hurt:

1. Too Much Boilerplate

Every new component came with 3 files:

Component.jsx
Component.module.css
index.js

That’s 3 tabs open.

3 places to make changes.

3 places for context switching.

And for what?

Just to change a background color or add some padding.

2. Naming Things Was Slowing Us Down

What do you call a blue button with white text?

primaryButton? blueWhiteBtn? btnPrimary?

There was no standard.

Every developer had their own naming logic.

Code reviews were filled with nitpicks about class names instead of functionality.

3. Repeating Patterns, Again and Again

Let’s say you needed a card component:

.card {
  padding: 16px;  border-radius: 8px;  background-color: white;  box-shadow: 0 2px 6px rgba(0,0,0,0.1);}

This pattern existed in six different files.

Each one slightly different.

Each one a future bug waiting to happen.

We thought “CSS Modules” would bring consistency.

But in reality, it just made repetition harder to spot.

What We Hoped Tailwind Would Solve

We weren’t looking for a silver bullet.

We just wanted:

→ Fewer decisions

→ Less repetition

→ Faster iteration

Tailwind offered all three.

Let’s revisit that card example, now in Tailwind:

<div className="p-4 rounded-lg bg-white shadow-md">  {/* card content */}</div>

No switching files.

No naming classes.

No wondering if someone already built this before.

We could move fast, make UI changes directly in the markup,

and keep styling close to the logic.

That’s what we needed.

It wasn’t just about writing less CSS.

It was about thinking less about CSS

— and focusing more on building features.

What Changed in Our Stack

The Setup We Had Before

For over a year, our frontend stack stayed the same.

We used:

→ React (with CRA)

→ Sass for styling

→ A few custom utility functions

→ Manual state management with useState and useContext

It worked, until it didn’t.

We noticed a few things:

  • Styling grew out of control
  • Styles weren’t consistent
  • New devs found it hard to onboard
  • Shared components were hard to scale

We needed something that made building UI faster and more maintainable.

The Switch: Tailwind, Vite, and Headless UI

We didn’t just add Tailwind CSS.

We rebuilt the stack for performance and scale.

Here’s the new stack:

→ React (but with Vite instead of CRA)

→ Tailwind CSS for styling

→ Headless UI for components

→ Zustand for state management

→ TypeScript for safety

The results?

→ Styles are consistent across all components

→ The UI layer is more composable

→ New devs can build features faster

Let’s break down the biggest change: Tailwind CSS integration.

Tailwind CSS in Action

We moved away from Sass files to utility classes.

Instead of jumping between multiple files, everything lives in the component.

Here’s a side-by-side comparison:

Before (Sass):

// Button.jsxexport const Button = ({ children }) => {
  return <button className="btn">{children}</button>;}
/* styles.scss */.btn {
  background-color: #333;  color: #fff;  padding: 12px 20px;  border-radius: 4px;}

After (Tailwind):

export const Button = ({ children }) => {
  return (
    <button className="bg-gray-800 text-white px-5 py-3 rounded">      {children}    </button>  );};

Now the styles:

→ Are visible right where the logic is

→ Don’t need custom naming

→ Don’t require context switching

Why Vite?

Create React App was slowing us down.

Cold starts were painful.

Vite changed that.

→ It’s lightning fast

→ Supports hot reload instantly

→ Plays well with Tailwind and PostCSS

Here’s how we initialized the new setup:

npm create vite@latest my-app --template react-ts
cd my-app
npm install
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Then, we added this to tailwind.config.js:

module.exports = {
  content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],  theme: {
    extend: {},  },  plugins: [],};

And inside src/index.css:

@tailwind base;@tailwind components;@tailwind utilities;

Done. Just like that, Tailwind is ready.

Small Change. Big Impact.

This change simplified development.

Our UI is now easier to scale, easier to read, and easier to teach.

We didn’t just swap tools.

We made a decision to design for clarity, speed, and maintainability.

And that shows in every pull request.

Planning the Migration

Before you change a single line of code, you need a solid plan. This is the stage where most teams either set themselves up for success — or future chaos.

Let’s break it down into two key steps:

  • Inventorying existing components
  • Deciding the migration strategy

1. Inventorying Existing Components

You can’t migrate what you don’t understand.

Start by mapping out everything that makes up your current frontend. That means:

  • Pages
  • Layouts
  • Shared components
  • API calls
  • Routing logic
  • State management patterns
  • Third-party libraries

If your codebase is in React, you can script some of this.

đź§© Example: Inventory React Components with a Simple Script

Here’s a quick Node.js script using @babel/parser to scan all your .js or .jsx files and list components:

// tools/inventory.jsconst fs = require('fs');const path = require('path');const parser = require('@babel/parser');const traverse = require('@babel/traverse').default;function findComponents(dir) {
  const files = fs.readdirSync(dir);  for (const file of files) {
    const fullPath = path.join(dir, file);    const stat = fs.statSync(fullPath);    if (stat.isDirectory()) {
      findComponents(fullPath);    } else if (fullPath.endsWith('.jsx') || fullPath.endsWith('.js')) {
      const code = fs.readFileSync(fullPath, 'utf-8');      const ast = parser.parse(code, {
        sourceType: 'module',        plugins: ['jsx'],      });      traverse(ast, {
        enter(path) {
          if (
            path.node.type === 'FunctionDeclaration' &&            /^[A-Z]/.test(path.node.id.name)
          ) {
            console.log(`Component: ${path.node.id.name} in ${fullPath}`);          }
        },      });    }
  }
}
findComponents('./src');

Run this with:

node tools/inventory.js

This gives you a basic list of all top-level function components. You can extend this to detect class components, hooks, or specific props.

The goal: Know what you have, so you know what to move.

2. Deciding the Migration Strategy

There’s no one-size-fits-all answer. You have two main options:

A. All-at-once migration (big bang)

This means you rewrite the whole frontend in the new stack and switch over in one go.

When to choose this:

  • Your app is small or still early-stage
  • The current tech stack is outdated and not worth patching
  • You have enough resources to handle a full rewrite

Risks:

  • Long development time with no user-visible progress
  • Pressure to ship early can lead to bugs

B. Gradual migration (incremental)

You migrate one feature or one route at a time, keeping both the old and new stack running together.

When to choose this:

  • Your app is large and in active use
  • You can’t afford long periods without shipping updates
  • You want to test things in production, slowly

Common patterns:

  • Use a micro-frontend architecture
  • Mount new pages or widgets in isolated routes
  • Use a wrapper shell layout (like a legacy app shell)

Example: Wrap New React App Inside Legacy

Let’s say you’re moving from jQuery to React. You can mount the React app inside a div controlled by the old stack:

<!-- legacy HTML --><div id="app-root"></div><script>  // Somewhere in the legacy code  $(document).ready(function () {
    $('#app-root').load('/path/to/react-bundle.js', function () {
      ReactDOM.render(<NewComponent />, document.getElementById('app-root'));    });  });</script>

Over time, more routes or sections can be replaced with React — until you’ve phased out the old stack entirely.

Key Takeaway

Migration isn’t just a technical project — it’s a planning problem.

Know what you have. Choose how to move it. Make tradeoffs visible to everyone on the team.

Whether you go big bang or slow rollout, the success of your migration depends more on planning than code.

Refactoring Patterns in Frontend Code

From className imports to utility-first styles

Let’s talk about a common shift many teams go through.

You start with CSS modules or className imports like this:

import styles from './Button.module.css';export default function Button({ primary }) {
  return (
    <button className={primary ? styles.primary : styles.secondary}>      Click me
    </button>  );}

It works.

But it gets harder when your components grow.

Adding variants?

Customizing for breakpoints?

Combining two different states?

It turns into a mess of logic and nested class names.

So you switch to utility-first.

Utility-First with Tailwind

Tailwind makes styling inline.

You write your classes right where you write your markup.

Here’s that same button refactored:

export default function Button({ primary }) {
  return (
    <button      className={`px-4 py-2 rounded ${        primary ? 'bg-blue-600 text-white' : 'bg-gray-200 text-black'      }`}    >      Click me
    </button>  );}

Cleaner.

No more separate .css files.

No more wondering where a style is coming from.

But once you get here, the next problem shows up:

Conditional logic starts crowding your class strings.

Let’s fix that too.

Handling Conditional Styling

Instead of using messy string templates, use a helper.

Most teams use clsx or classnames.

Here’s how it looks with clsx:

npm install clsx
import { clsx } from 'clsx';export default function Button({ primary, disabled }) {
  return (
    <button      className={clsx(
        'px-4 py-2 rounded transition',        primary ? 'bg-blue-600 text-white' : 'bg-gray-200 text-black',        disabled && 'opacity-50 cursor-not-allowed'      )}    >      Click me
    </button>  );}

Readable.

Scales better.

You can see every conditional style clearly.

No guessing.

Managing Responsive Styles with Tailwind

Tailwind makes responsive design easy.

You don’t need media queries.

You just prefix styles with breakpoints.

Let’s say we want:

  • Full width on mobile
  • Inline on desktop

Here’s how:

export default function Button() {
  return (
    <button className="w-full md:w-auto px-4 py-2 bg-blue-600 text-white rounded">      Click me
    </button>  );}

And it doesn’t stop there.

You can conditionally apply hover states, focus states, dark mode, and more—

All inline, all visible.

Final Example: Button with Variants and Responsive Layout

import { clsx } from 'clsx';export default function Button({ primary, size = 'md', fullWidth }) {
  return (
    <button      className={clsx(
        'rounded transition font-medium',        {
          sm: 'px-3 py-1 text-sm',          md: 'px-4 py-2 text-base',          lg: 'px-6 py-3 text-lg',        }[size],        primary ? 'bg-blue-600 text-white' : 'bg-gray-200 text-black',        fullWidth ? 'w-full' : 'w-auto',        'md:w-auto' // Override to inline on medium+ screens      )}    >      Click me
    </button>  );}

No CSS files.

No class imports.

Just pure logic, clearly written.

You can see exactly what’s happening.

The Takeaway

Refactoring to Tailwind isn’t just a syntax change.

It’s a mindset shift.

You go from organizing styles elsewhere

to seeing styles as part of your component logic.

You:

→ Stop hunting through CSS files

→ Start writing styles faster

→ Make your components easier to read

And as your app grows, your styles don’t fall apart.

Just utility-first, composable, and responsive.

Real Challenges We Faced

What no one tells you when switching to Tailwind

Tailwind looks great on the surface.

Fast. Utility-first. No naming debates.

But real-world usage?

That’s where the true test begins.

Here’s what we learned the hard way.

1. Naming Conventions vs Utility Classes

Before Tailwind, we had strict naming rules:

btn-primary, card-header, layout-grid.

It made styles feel reusable.

It also made reviewing PRs easier—because the class names explained the intent.

But with Tailwind, that abstraction is gone.

You see this instead:

<div class="bg-white p-4 shadow rounded-lg">  <h2 class="text-xl font-semibold mb-2">Title</h2>  <p class="text-gray-600 text-sm">Description</p></div>

The design is in the markup.

Not behind a name.

This was polarizing.

Some teammates loved the visibility.

Others missed semantic class names.

What worked for us:

  • We still use a component-name layer for layout-heavy blocks.

    e.g. HeroSection, DashboardCard.

  • But for anything small or atomic, we just use utility classes directly.

It’s not either-or.

It’s about choosing utility where it helps, and abstraction where it matters.

2. File Size and Purging Unused Styles

Tailwind comes with thousands of classes.

If you’re not careful, your final CSS can be huge.

Our first production build?

3.5MB of CSS.

Most of it unused.

This happened because we hadn’t set up purging correctly.

Tailwind scans your files and removes unused classes—

but it only works if you tell it where to look.

Fix: Configure content paths in tailwind.config.js

module.exports = {
  content: [
    './src/**/*.{js,jsx,ts,tsx}', // Scan all component files    './public/index.html',  ],  theme: {
    extend: {},  },  plugins: [],}

Once we did this, our CSS dropped from 3.5MB → 28KB.

Also:

Avoid using dynamic class names like this:

const color = 'blue';<div className={`text-${color}-600`} />

Tailwind’s purge won’t catch it.

Use full class names instead:

<div className="text-blue-600" />

Or use a safe list in the config:

safelist: ['text-blue-600', 'text-red-600', 'text-green-600']

3. Learning Curve for the Team

This surprised us the most.

Tailwind looks easy.

But thinking in utility classes? That’s a mindset shift.

Here’s what slowed us down:

  • People tried to “convert” designs 1:1 from Figma without understanding spacing scales.
  • Some wrote long, unreadable class strings.
  • Others didn’t know about breakpoints, dark mode, or variants.

Our solution?

We wrote a team guide:

– How we use spacing (p-4, not p-[16px])

– When to use custom class names

– Tips for readability (like breaking long class lists into multiple lines)

We also used Tailwind’s official playground:

https://play.tailwindcss.com/

Team members could paste designs and experiment freely.

It sped up onboarding without breaking real code.

The Takeaway

Adopting Tailwind isn’t just a tool change.

It’s a workflow change.

You’ll hit friction around:

→ Naming things

→ Controlling file size

→ Teaching your team a new mental model

But if you’re intentional?

Tailwind simplifies everything.

You write faster.

Your UI becomes predictable.

And your team spends less time arguing over CSS.

That’s worth the effort.

How We Kept Components Reusable

Without turning our JSX into spaghetti

Tailwind gives you speed.

But it also tempts you to cram everything into one line.

Before long, your components look like this:

<button className="w-full md:w-auto px-4 py-2 bg-blue-600 text-white hover:bg-blue-700 rounded-lg text-sm font-semibold shadow">  Click me
</button>

Works fine.

But now imagine repeating this in 6 places.

And then needing a slight variation for a secondary button.

That’s how “inline mess” creeps in.

Here’s how we solved it.

1. Avoiding Inline Mess

We stopped repeating utility classes.

Instead, we broke them into reusable class recipes.

Example: the Button component.

// utils/classNames.jsimport { clsx } from 'clsx';export const baseButton = 'px-4 py-2 rounded-lg text-sm font-semibold shadow transition';export const buttonVariants = {
  primary: 'bg-blue-600 text-white hover:bg-blue-700',  secondary: 'bg-gray-200 text-black hover:bg-gray-300',  disabled: 'opacity-50 cursor-not-allowed',};

Then use it like this:

import { clsx } from 'clsx';import { baseButton, buttonVariants } from './utils/classNames';export default function Button({ type = 'primary', disabled, children }) {
  return (
    <button      disabled={disabled}      className={clsx(
        baseButton,        buttonVariants[type],        disabled && buttonVariants.disabled      )}    >      {children}    </button>  );}

Cleaner.

Every class has a purpose.

And if we change styles later, it only happens in one file.

2. Building with Composition in Mind

Rather than creating huge all-in-one components,

we focused on small, focused parts that could be composed.

Take a Card component.

We didn’t write this:

<div className="bg-white p-4 rounded-lg shadow-md">  <h2 className="text-lg font-bold mb-2">Title</h2>  <p className="text-gray-600">Content</p></div>

We wrote composable components:

export function Card({ children }) {
  return <div className="bg-white p-4 rounded-lg shadow-md">{children}</div>;}
export function CardTitle({ children }) {
  return <h2 className="text-lg font-bold mb-2">{children}</h2>;}
export function CardBody({ children }) {
  return <p className="text-gray-600">{children}</p>;}

Used like this:

<Card>  <CardTitle>Title</CardTitle>  <CardBody>Content</CardBody></Card>

Now we can:

→ Add spacing logic to just the Card

→ Add variants to CardBody without breaking others

→ Reuse parts across different pages

3. Creating Tailwind “Recipes” for Consistent Styling

We borrowed an idea from design systems:

Recipes.

In practice, a “recipe” is just a shared set of class rules

for common elements: headers, inputs, badges, etc.

We put these in a single file, named clearly:

// utils/recipes.jsexport const inputBase =  'block w-full px-4 py-2 border rounded-md text-sm shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500';export const badgeBase =  'inline-block px-2 py-1 text-xs font-medium rounded';export const badgeVariants = {
  success: 'bg-green-100 text-green-800',  warning: 'bg-yellow-100 text-yellow-800',  error: 'bg-red-100 text-red-800',};

Then use it:

import { clsx } from 'clsx';import { badgeBase, badgeVariants } from './utils/recipes';export function Badge({ type = 'success', children }) {
  return (
    <span className={clsx(badgeBase, badgeVariants[type])}>      {children}    </span>  );}

No guessing.

No style drift.

Just consistent output across the app.

The Takeaway

Tailwind can be messy if you let it.

But it can be modular and reusable if you’re deliberate.

Here’s what helped us:

→ Extract shared classes into named recipes

→ Compose UI from small, focused pieces

→ Build logic-driven styling, not just inline blobs

The goal isn’t to stop using utilities.

The goal is to structure your code so it stays maintainable

— even when the project grows.

That’s how we kept our components clean, composable, and easy to reuse.

Developer Experience: Before vs After

How Tailwind changed how we build, debug, and collaborate

Tools don’t just affect code.

They shape how your team works.

Before Tailwind, our stack felt “normal.”

CSS Modules, BEM conventions, SCSS in some files, inline styles in others.

But there were friction points we didn’t notice—until they were gone.

Here’s what changed.

1. Setup and Onboarding

Before Tailwind:

New devs spent the first few days figuring out:

  • Where styles live
  • What naming rules to follow
  • Why some components used SCSS and others didn’t
  • What btn--lg--outline meant

Even after they got the code running, they didn’t feel productive.

After Tailwind:

Onboarding became instant.

Install Tailwind. Start writing classes.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Add to your CSS:

@tailwind base;@tailwind components;@tailwind utilities;

Set your purge/content paths:

// tailwind.config.jscontent: ['./src/**/*.{js,jsx,ts,tsx}']

That’s it.

New team members could look at a component and immediately know what each class did—because Tailwind names follow a system.

No more searching for a .scss file.

No more guessing what container__header--dark really means.

2. Debugging and Collaboration

Before:

Debugging styles meant digging into:

  • Deeply nested CSS files
  • Overlapping rules
  • Conflicts from global styles
  • !important overrides from legacy stylesheets

One teammate changed a margin-top in one file, and it broke spacing in another view.

Nobody could figure out why.

After:

We debug styles in the component.

If a button looks off, we open the file and read the class list.

<button className="px-4 py-2 bg-blue-600 text-white rounded-lg" />

No side effects.

No hidden CSS overrides.

If something’s wrong, it’s right there.

And we can fix it without breaking something 6 files away.

Collaboration improved too.

Designers and engineers started working off the same utility scale—

Tailwind’s spacing, colors, typography.

Everyone spoke the same language.

That cut down feedback loops.

3. Speed of Development

Here’s the real shift.

Before Tailwind:

Building a new component meant:

  • Naming it
  • Writing markup
  • Creating a new .module.css or .scss file
  • Adding styles
  • Adjusting until it looked right
  • Reviewing to make sure styles weren’t duplicating existing ones

Even small UI changes took time.

After Tailwind:

It’s just: markup → class names → done.

<div className="flex items-center justify-between p-4 bg-gray-100 border rounded-lg">  <span className="text-sm font-medium text-gray-700">Item name</span>  <span className="text-sm text-gray-500">Qty: 3</span></div>

No switching files.

No naming debates.

No “where should this style live?” conversations.

We move fast.

And because styles are co-located with components, refactoring is easier too.

The Takeaway

The difference in developer experience wasn’t subtle.

We:

→ Onboard faster

→ Debug in seconds

→ Write styles with confidence

→ Work better with designers

→ Ship features faster

There was a learning curve at first, yes.

But once we adjusted, we never looked back.

Tailwind isn’t just about utility classes—

it’s about making frontend work feel smoother and simpler every day.

The Results

What actually changed after refactoring to Tailwind

We didn’t switch to Tailwind for fun.

We did it because our old setup was slowing us down.

Here’s what we saw—after months of using it in production.

1. Codebase Before and After

Before Tailwind

We had styles split across:

  • CSS modules
  • Global SCSS files
  • A growing list of utility helper classes
  • Inconsistent naming rules

Each component had 3–4 files attached.

Half of our PR comments were about spacing or alignment.

Here’s what a basic card component used to look like:

// Card.jsximport styles from './Card.module.css';export default function Card() {
  return (
    <div className={styles.card}>      <h2 className={styles.title}>Title</h2>      <p className={styles.description}>Some content here.</p>    </div>  );}
/* Card.module.css */.card {
  padding: 16px;  background-color: white;  border-radius: 8px;  box-shadow: 0 1px 3px rgba(0,0,0,0.1);}
.title {
  font-size: 1.125rem;  font-weight: 600;  margin-bottom: 8px;}
.description {
  font-size: 0.875rem;  color: #666;}

After Tailwind

We reduced it to one file. One component. One glance.

export default function Card() {
  return (
    <div className="p-4 bg-white rounded-lg shadow">      <h2 className="text-lg font-semibold mb-2">Title</h2>      <p className="text-sm text-gray-600">Some content here.</p>    </div>  );}

No extra imports.

No naming debates.

And if we want to tweak padding or color, we do it right there.

We deleted over 3,000 lines of custom CSS in the first cleanup.

And we haven’t needed to add much since.

2. Performance Impact

This one surprised us.

Tailwind loads with a huge number of utility classes.

But thanks to proper purge setup, our final CSS is tiny.

Before:

  • Built CSS size: ~900 KB
  • We used maybe 15% of it

After:

  • Tailwind CSS size (post-purge): < 30 KB
  • Every class in use is visible in the markup

And since styles are atomic, we have zero cascade bugs.

That means:

→ No !important hacks

→ No dead CSS

→ No side effects from one style affecting another

Our pages load faster.

Styles are scoped by default.

And Lighthouse scores improved across the board.

3. Design Consistency

Before Tailwind, we had:

  • 6 different button styles
  • 4 levels of gray (none of them matching the design system)
  • Inconsistent spacing, even in the same layout

Now we use:

  • One design system (Tailwind’s scale)
  • Shared component recipes for buttons, cards, alerts, and forms
  • Predefined spacing, font sizes, colors, and shadows—used everywhere

This is the real win.

Developers stopped inventing new styles.

They started using the same building blocks, again and again.

<Button variant="primary" size="md">Submit</Button>

Simple. Consistent. Predictable.

Design reviews got shorter.

Frontends got tighter.

New components look like they belong—without asking a designer every time.

The Takeaway

Tailwind didn’t just change our code.

It changed our culture.

We:

→ Write fewer styles

→ Repeat less

→ Deliver faster

→ Spend more time on product logic—not pixel nudging

Our codebase is smaller, more consistent, and easier to maintain.

Our UI is cleaner and more stable.

And our team? More aligned than ever.

Sometimes a tool does exactly what it says on the tin.

Tailwind did.

Would We Do It Again?

What we’d tell ourselves if we were starting fresh

After months of using Tailwind in production, we asked ourselves a simple question:

Would we go through the switch again?

Short answer: Yes. Absolutely.

But that doesn’t mean it’s perfect for everyone.

Here’s what we’ve learned—what worked, what didn’t, and when you might want to think twice.

Final Thoughts

Tailwind simplified our frontend.

We shipped features faster.

We spent less time naming things.

We eliminated the overhead of maintaining a custom design system.

And we reduced bugs—because everything was visible, atomic, and easy to test.

It didn’t happen overnight.

There was a learning curve.

But once the team clicked with the utility-first mindset, productivity picked up fast.

Things we loved:

  • Utility classes that map 1:1 with design tokens
  • One source of truth (the tailwind.config.js)
  • Easier debugging and cleaner PRs
  • Styling inside the component—not across three files

When Tailwind Makes Sense

Tailwind is a great fit if:

✅ You’re building product UIs

âś… You want fast iteration with minimal CSS overhead

âś… Your team prefers code over config

✅ You don’t want to maintain a large design system

✅ You’re okay with abstracting less in exchange for speed and clarity

It especially shines in component-based frameworks like React, Vue, or Svelte, where styles live with logic.

For example:

function Alert({ type = 'info', children }) {
  const styles = {
    info: 'bg-blue-100 text-blue-700',    success: 'bg-green-100 text-green-700',    error: 'bg-red-100 text-red-700',  };  return (
    <div className={`p-4 rounded ${styles[type]}`}>      {children}    </div>  );}

No CSS files.

No side effects.

Just styles that reflect the state of your component.

When Tailwind Might Not Be the Right Fit

Tailwind isn’t for every project.

❌ If you’re building a heavily branded marketing site, you may want more fine-tuned custom design.

❌ If your team prefers strong semantic class names (like form-header--highlight), utility-first can feel like noise.

❌ If your designers hand over Figma files with 20 custom shadows and exact pixel values, Tailwind’s constraint system might feel limiting.

Also:

If you have a large legacy codebase built on global CSS, migrating can take time—and may not be worth it unless you’re already refactoring.

The Takeaway

Tailwind helped us:

→ Move faster

→ Collaborate better

→ Write cleaner UI code

But like any tool, it’s not about being “right.”

It’s about being right for your team, your product, and your goals.

For us, Tailwind delivered.

And if we were starting from scratch today?

We’d choose it again—no hesitation.

Tips If You’re Migrating Too

How to move to Tailwind without breaking everything

Switching to Tailwind is a big decision.

It changes how you write styles, organize components, and think about UI.

But it doesn’t need to be all-or-nothing.

We migrated gradually—component by component—and here’s what helped.

1. What to Do Before You Start

âś… Audit your current styles

Look for:

  • Common patterns (buttons, cards, spacing, typography)
  • Reusable UI blocks
  • Dead CSS files and duplicate classes

The goal is to identify what you actually need, not migrate everything blindly.

âś… Set up Tailwind in parallel

You don’t need to rip out your old CSS overnight.

Start by adding Tailwind to your project alongside your existing styles.

npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p

Then update your main CSS:

@tailwind base;@tailwind components;@tailwind utilities;

Finally, configure purge paths in tailwind.config.js:

module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx,html}'],};

Now you can start using Tailwind in just one component—without breaking the rest of your app.

2. Common Pitfalls to Avoid

đźš« Mixing global CSS and Tailwind without a plan

Don’t apply Tailwind classes on top of legacy styles and hope it “just works.”

It won’t.

We created a rule:

If you touch a component, fully migrate it to Tailwind.

This avoided weird bugs caused by style conflicts.

đźš« Skipping the design system setup

You might be tempted to use Tailwind “as-is.”

But you’ll quickly want consistent colors, fonts, and spacing.

Customize your Tailwind config early:

theme: {
  extend: {
    colors: {
      brand: {
        primary: '#2563eb',  // Blue        secondary: '#1e40af', // Darker blue      },    },    fontFamily: {
      sans: ['Inter', 'sans-serif'],    },  },}

Now you can use classes like bg-brand-primary or font-sans everywhere.

đźš« Writing unreadable class blobs

When devs start with Tailwind, they often write this:

<div className="bg-white px-4 py-3 rounded shadow text-sm text-gray-800 font-medium border border-gray-200 hover:bg-gray-50">  Item
</div>

Instead, break it up or abstract it:

const itemClass = `  bg-white px-4 py-3 rounded shadow  text-sm text-gray-800 font-medium  border border-gray-200 hover:bg-gray-50`;<div className={itemClass}>Item</div>

Or extract it into a reusable component if used in multiple places.

3. Small Wins That Build Momentum

Start with components that are:

âś… Easy to isolate

âś… Repeated often

âś… Visually simple (buttons, inputs, cards)

Here’s how we started:

Example: Migrating a button

Before (CSS Module)

import styles from './Button.module.css';export function Button() {
  return <button className={styles.primary}>Submit</button>;}

After (Tailwind)

export function Button() {
  return (
    <button className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">      Submit
    </button>  );}

We started with buttons and alerts—

then moved to layout wrappers like Card, Section, and Header.

Each small win made the next one easier.

The Takeaway

You don’t need to rewrite your entire codebase.

Start small. Move one component at a time.

Here’s what helped us:

→ Audit styles before touching code

→ Set up a custom config early

→ Migrate isolated components first

→ Avoid mixing legacy and Tailwind styles

→ Keep class lists readable

→ Celebrate small wins and keep momentum

Tailwind rewards teams who take the time to transition thoughtfully.

You’ll end up with less CSS, faster development, and more consistent UI.

And if you stick with it, the payoff compounds—fast.