Goodbye CSS Modules, Hello TailwindCSS

Engineering

Implementing an app redesign is never routine nor easy. Two weeks after I was hired at Polytomic, I began implementing the app's first redesign since the founding of the company. The technology choices and how to get it done were left up to me.

Our frontend codebase is a single-page application powered by Create React App (CRA), written in TypeScript, and using GraphQL for the API. The existing styling approach used CSS Modules without a design system.

CSS Modules are CSS files in which all class and animation names are scoped locally by default. They get compiled as part of the build step—with bundler technology like Webpack—and are natively supported by CRA.

For example, using plain CSS in React:


/* Button.css */
.Button {
  padding: 20px;
}


// Button.tsx
import * as React 'react'
// Tell webpack that Button.tsx uses these styles
import './Button.css'

export function Button () {
  // You can use them as regular CSS styles
  return <div className='Button'>Button</div>
}


<!-- Corresponding HTML -->
<div class='Button'>Button</div>

For example, using CSS Modules in React:


/* Button.module.css */
.error {
  background-color: red;
}

/* another-stylesheet.css */
.error {
  color: red;
}


// Button.tsx
import * as React from 'react'
// Import css modules stylesheet as styles
import styles from './Button.module.css' 
// Import regular stylesheet
import './another-stylesheet.css' 

export function Button() {
  // reference as a js object
  return <button className={styles.error}>Error Button</button>
}


<!-- Corresponding HTML -->
<!--
  This button has a red background but not red text.
  No clashes from other .error class names.
-->
<button class='Button_error_ax7yz'>Error Button</button>

The advantages of CSS Modules are the elimination of CSS conflicts across files due to local scoping, explicit dependencies, and the prevention of global CSS. But, preventing global CSS removes the expressive power of the “cascading” part of Cascading Style Sheets (CSS). Additionally, locally scoping CSS class names and IDs do not prevent duplicate CSS in other files. Defining margin-top: 0; multiple times in different CSS files will not be mitigated by CSS Modules.

The biggest drawback to CSS Modules is the lack of design system utilities. As a frontend team of one, this was a deal breaker. I needed a styling approach both powerful and expressive with utilities to create and maintain a design system. 

What is a design system?

A design system is a collection of reusable components and styles for building a cohesive user interface. It abstracts out specific values in favor of generic tokens or variables.

For example, a color palette will use names communicating intention, like color-success or color-warning, instead of literal values or color names, such as green or #FFA500. Updating the value of color-success becomes a single-line change that propagates to all instances of that token while retaining the same intention.

Design systems define intention across all aspects of a user interface, from simple colors to complex components. Constraints on styling choices ensure available options work together harmoniously. Constructing a visually imbalanced layout or component becomes more difficult than with CSS Modules and no design system in place.

Twitter's Bootstrap pioneered the concept of a CSS framework and component library. It is an excellent choice for pre-made components. However, Bootstrap's biggest feature—those pre-made components—can be its biggest flaw. 

Bootstrap is a closed system. In past projects, I have rewritten and redefined large portions of the Bootstrap library to customize style variables and components the configuration did not allow to be customized. But, if customization is the default posture, the efficiencies of Bootstrap are lost. Using CSS Modules would result in the same amount of work for the same result as a customized Bootstrap.

With design systems and customization in mind, I narrowed down my options to Tailwind and Theme UI, which is powered by Emotion, a CSS-in-JS library. Both libraries offer powerful design system configuration but—crucially—did not lock me into specific component architectures. I wanted to avoid the tedious redefinition process present in pre-made component libraries like Bootstrap.

Comparing Tailwind to Bootstrap, Tailwind is a collection of styling primitives based on configuration. It is the configuration aspect of Bootstrap without the pre-made components or lock-in to Bootstrap's component and configuration architecture. 

Tailwind has its own paid component library called Tailwind UI that is a modern Bootstrap equivalent. It leverages the flexibility of Tailwind's configuration while providing high quality component recipes that are trivial to override and customize. I have used Tailwind UI as a starting point and inspiration for my own components.

What is Tailwind?

Tailwind is a utility-first CSS framework and build step packed with classes like flex, pt-4, text-center, and rotate-90 that can be composed to build any design, directly in markup.

Utility class CSS is an approach to writing CSS that inverts the definition of style rules. Historically, CSS is written with multiple style rules attached to a single class, ID, or HTML element.

For example, in plain CSS:


/* button.css */
.button {
  background-color: rgb(243, 244, 246);	
  border-radius: .5rem;	
  color: rgb(0, 0, 0);	
  cursor: pointer;	
  font-family: 'Inter var', 'Helvetica Neue', Arial, sans-serif;	
  font-size: 1rem;	
  font-weight: 500;	
  line-height: 1.5rem;	
  padding-top: .75rem;	
  padding-bottom: .75rem;	
  text-align: center;
}


<!-- Corresponding HTML -->
<div class='button'>Button</div>


Utility classes define each style rule as a separate class to compose individually in the markup instead defining the necessary style rules under a single class.

For example, in stylesheet.css:


/* stylesheet.css */
.font-sans {	
  font-family: 'Inter var', 'Helvetica Neue', Arial, sans-serif;
}
.text-base {	
  font-size: 1rem;	
  line-height: 1.5rem;
}
.font-medium {	
  font-weight: 500;
}
.rounded-lg {	
  border-radius: .5rem;
}
.bg-gray-100 {	
  background-color: rgb(243, 244, 246);
}
.text-black {	
  color: rgb(0, 0, 0);
}
.py-3 {	
  padding-top: .75rem;	
  padding-bottom: .75rem;
}
.text-center {	
  text-align: center;
}
.cursor-pointer {	
  cursor: pointer;
}


<!-- Corresponding HTML -->
<div class='text-base font-sans font-medium rounded-lg bg-gray-100 text-black py-3 text-center cursor-pointer'>Button</div>

Utility class CSS frameworks, such as Tailwind, generate these atomic classes based on a combination of design system defaults and user configuration. Every style combination possible from the configuration and defaults is generated, and, at build time, the unused classes are removed from the stylesheet. 

By staying within the constraints of the design system and Tailwind's API, using a new value for padding is as easy as putting py-2 into the markup. Tailwind generated this class in advance from the defaults and configuration. It also generated py-1 and py-4 but will remove them from the stylesheet if not added to the markup.

What is CSS-in-JS?

CSS-in-JS is a general philosophy to writing CSS inside of JavaScript files. All authorship of styling happens within JavaScript files and in a way native to JavaScript instead of native to CSS, like Tailwind. This allows CSS-in-JS styling to exploit the full expressive power of JavaScript.

Theme UI is a CSS-in-JS library for creating themeable user interfaces based on constraint-based design principles. It is the CSS-in-JS flavor to Tailwind’s plain CSS approach. Both accomplish the same task—design systems—but differ in their developer experience.

For example, Tailwind’s configuration file to override or extend the default configuration:


// tailwind.config.js
module.exports = {  
  theme: {    
    fontFamily: {      
      heading: ['Inter', 'system-ui', 'sans-serif'],      
      body: ['Inter', 'system-ui', 'sans-serif'],    
    },    
    colors: {      
      primary: {        
        50: '#eef2ff',        
        100: '#e0e7ff',        
        200: '#c7d2fe',        
        300: '#a5b4fc',        
        400: '#818cf8',        
        500: '#6366f1',        
        600: '#4f46e5',        
        700: '#4338ca',        
        800: '#3730a3',        
        900: '#312e81',      
      },      
      gray: {        
        50: '#fafafa',        
        100: '#f4f4f5',        
        200: '#e4e4e7',        
        300: '#d4d4d8',        
        400: '#a1a1aa',        
        500: '#71717a',        
        600: '#52525b',        
        700: '#3f3f46',        
        800: '#27272a',        
        900: '#18181b',      
      },    
    },  
  },
}


// app.tsx
export const App = () => (
  <div>
    <h1 className='font-heading text-primary-500'>
      Hello
    </h1>
  </div>
)

For example, Theme UI’s configuration object:


// theme.ts
import type { Theme } from 'theme-ui'

export const theme: Theme = {
  fonts: {
    body: 'system-ui, sans-serif',
    heading: '"Avenir Next", sans-serif',
    monospace: 'Menlo, monospace',
  },
  colors: {
    text: '#000',
    background: '#fff',
    primary: '#33e',
  },
}


// app.tsx
import { ThemeProvider } from 'theme-ui';
import { theme } from './theme'

export const App = () => (
  <ThemeProvider theme={theme}>
    <h1
      sx={{
        color: 'primary',
        fontFamily: 'heading',
      }}
    >
      Hello
    </h1>
  </ThemeProvider>
)

As both Tailwind and Theme UI help implement design systems, I chose Tailwind because its CSS output is not locked up inside the JavaScript runtime. Rather, it compiles into normal CSS classes. The JavaScript bundle size is smaller because style definitions are not intertwined with React code. Since Tailwind uses global, low-specificity CSS classes, stylesheet rule redefinition is eliminated and file size bloat is avoided.

Implementation strategy

I started my redesign implementation journey with a small, simple page in the app to convince myself using Tailwind was a viable strategy. Once I was confident Tailwind would work, I tackled the remaining pages in decreasing order of complexity. This is a method of de-risking the project: encounter uncertainty early while enthusiasm is high.

Feature and maintenance work regularly popped up throughout my implementation, and I would need to divert from the page I was redesigning to ship that code. Leaving my long-running redesign branch to make a feature on master introduced problems with synchronizing changes.

Every time I would implement a feature on master in the old design, I would need to rebase my redesign branch and re-implement the work, which could be simple restyling or serious refactoring. Compounding the pain of synchronizing new features was the other engineers doing their work. If I waited a couple days to rebase my redesign branch on master—as I naïvely did in the beginning—I would spend up to an hour working through the conflicts. In hindsight, consistently rebasing early and often was the right strategy.

Conclusion

The redesign shipped months ago, which gave me plenty of time to live with my decision to use Tailwind. My conclusion: the choice was an unambiguously good one. The main reasons are:

  • Speed: I can immediately begin writing styles in components because Tailwind is global and pre-generated from defaults and my configuration. Styles do not need to be imported or created by me.
  • Shared language: Polytomic’s design team uses Tailwind’s design system. When implementing a feature, I can count on design mocks sticking to Tailwind’s values and scales. 
  • Expressiveness: I have been able to execute every design given to me. Tailwind has not limited me. When I have used non-Tailwind CSS, it has been for specific browser overrides, mostly Safari.
  • Contextual adjustments: many components need CSS adjustments depending on their context. Changing a className string rather than overriding CSS via the cascade is less error prone for me. I eliminated the problem of specificity collisions.

It’s a choice that—in hindsight—I would make again. 

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.