A quest from Styled-Components to CSS Modules


A quest from Styled-Components to CSS Modules

The bad news is nothing lasts forever, The good news is nothing lasts forever.

In my past few years of React/Next development, I have become a die-hard supporter of CSS in JS, specifically Styled ComponentsIcon to represent this opens in a new tab. Most of my work projects and nearly all of my personal ones use it.

My first exposure to CSS in JS was to build a small React app for RuneScape, and my team's then lead front-end developer, getting immensely excited to show us theme-uiIcon to represent this opens in a new tab, and the concept of writing our styling in JavaScript. We had recently transitioned to React from various other approaches, so everything was relatively new.

A basic paint drawing of a person contradicting the statement 'you can't just use javascript for everything' by saying 'that's where you're wrong kiddo'.
Everything in js.

To any die-hard CSS coder (I can feel some of you shudder reading that), the initial concept of styling in JavaScript sounded like a nightmare, and I'll admit I (perhaps as usual) had my doubts. As we pressed on, starting to push this brave new world of remembering that all styling property names could no longer have dashes and never creating a .css file, it became apparent that there was beauty in the madness. A charm in building all in one place. A satisfying elegance that we could manipulate the site's visual display in the same file as the DOM itself, and it did not feel dirty. We could define objects and themes to share standard parts and concepts, and very soon, our first app had grown pretty fast.

Despite this, I loathed theme-ui. There were just too many awkward habits, and the breakpoint array system they used at the time (I hope they still do not use it), whilst being clever, was a developer maelstrom of JavaScript carnage.

Sadly, this app was shelved in favour of other projects, but it laid the foundation for a change in how I looked at the front-end. The company needed a global design system for their utilities, like account management and their upcoming game launcher. We were tasked with bringing this vision to life. We sat down with our passionate lead developer, who was even more excited to show us Styled-ComponentsIcon to represent this opens in a new tab. The cynic in me was again apprehensive as the gargantuan nightmare of another theme-ui esc showdown loomed. I was pleased to be wrong - Styled-Components was the answer we needed. The ability to form elements as custom components with styles inside strings, similar to CSS, was perfect. I was hooked, and soon we were building all manner of components in an atomic design system powered by StorybookIcon to represent this opens in a new tab. I took what I learnt from work projects and rebuilt my portfolio site in the same approach to deepen my understanding.

export const Root = styled.button<{ $variant: ButtonVariant }>`
  align-items: center;
  appearance: none;
  background: transparent;
  border: none;
  color: ${colors.whiteGhost};
  cursor: pointer;
  display: flex;
  font-size: ${fontSizes.medium.rem};
  font-weight: ${fontWeights.bold};
  height: ${({ $variant }) =>
    $variant === "standard" ? sizes.s48.rem : "auto"};
  justify-content: center;
  min-width: ${({ $variant }) =>
    $variant === "standard" ? sizes.s64.rem : "0"};
  max-width: ${sizes.s256.rem};
  padding: 0
    ${({ $variant }) => ($variant === "standard" ? sizes.s48.rem : "0")};
  position: relative;
  transition: padding ${animationDurationCSS(0.5)};
  z-index: 0;
  &::before,
  &::after {
    background: ${colors.greenGrass};
    clip-path: ${buttonClipPath};
    content: "";
    display: ${({ $variant }) => ($variant === "standard" ? "block" : "none")};
    height: 100%;
    left: 50%;
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    transition: all ${animationDurationCSS(0.5)};
    width: calc(100% - ${sizes.s32.rem});
    z-index: -1;
  }
`

A button component styled using Styled-Components.

Soon, the system was embraced across the company for various projects, and we upheld an unwavering commitment to excellence, ensuring a remarkable end product. The system (other than React) was framework agnostic, allowing it to be used in vanilla React and React/Next.

It was all going so well - and then came the inevitable bumps in the road. I work with a very talented full-stack principal engineer (I'm hoping he doesn't read this part, else he is bound to use it in my stand-up at some point) who has a habit of being right about opinions on the direction of web technology. Making a design system using Styled-Components had sometimes become frustratingly annoying to bundle. Sometimes the theme provider would not work, or the fonts would not load, and the consumer had to perform a fair amount of webpack configuration manually. He would highlight this as a source of concern, but I was not so fussed as, eventually, we always made it work.

Then React and Next shifted their ideology to focus on SSR as the recommended approach for all their apps. Next.js even defaulted to it, and guess what? Styled-Components did not work well with it. Now, you can set up concurrent server-side rendering with stylesheet rehydration, which, in simple terms, creates a server stylesheet from your styled component code. Still, now the overhead of using styled components was becoming very heavy.

Begrudgingly, despite my best efforts, it was probably the beginning of the end for styled components in my work. By this point, I had built imposing components with them, from CSS-driven cloud animations to three-dimensional spinners and even a weather station. But it wasn't easy to make it work well with SSR, and to quote Bob Dylan...

The Times They Are A-Changin.

Last month, it was announced that Styled-Components would enter maintenance mode as it was becoming difficult for the maintainer to update, and Vercel decided to advise against it in favour of other options.

So, an exciting new project comes along at work, and I'm delivering it with another senior developer who recently joined my team. I'm at a stage in my career now where I realise that I have learnt enough to be aware that I know but a small slice of the massive world of web technology approaches. But I have tried to listen better to those around me and think about who I am building a solution for and working on it with.

So this time, before I backed Jagex into a corner, I had better get the next style approach right. After several late nights, I migrated my portfolio site and design system from styled components to CSS modules. Despite being used for my portfolio site, the system has nearly 80 entities formed from hundreds of styled components. Even more complexly, the JS-based styling is integrated into the documentation to explain everything and avoid code duplication.

I knew that native CSS (although improving rapidly) was not going to be able to do this without some horrible nasty hackery and I wanted the variables available immediately rather than waiting for DOM render. So the only solution here was to use a preprocessor - in this case, SCSS.

I had used SCSS in my earlier years for several projects, and back in the early days of CSS3, it was a dream having access to mathematical operations and mixins that native CSS could not do. Most importantly, though, was that in SCSS modules, I could use the export operator to import my styles into MDX and TSX. Maps needed to be encoded into a JSON string for this to work and then parsed back into an object in JS, but it was successful!

A view of my design system showing the sizing map of pixel values
A sass map exported from scss and converted into a JavaScript object for display in my design system.

The process of converting my design system was fiddly at first. I missed the friendly names I had given elements in Styled-Components, but it was great not to stick a "use client" marker at the top of every file where I declared a styled component.

I could no longer use transient props, so I had to take better advantage of CSS variables declared inside the style attribute (that TSX still does not like and I do not know why, but sometimes you have to tell the TypeScript compiler you know better) to deal with variations in styles. CSS variables are nicely scoped to the element and its children and any keyframe animations applied to that element. This was a massive lightbulb moment for reducing the amount of CSS compiled on build.

export function Flight({
  skyColor = "#7fb4c7",
  numberOfClouds = 30,
  className,
  style,
  ...rest
}: FlightProps) {
  const flightLCG = makeLCG();
  const clouds: ReactNode[] = [];
  for (let i = 0; i < numberOfClouds; i++) {
    const x = (100 / numberOfClouds) * i;
    clouds.push(
      <Cloud
        className={styles.cloud}
        key={`cloud${i}`}
        data-testid={`${Flight.name}Cloud`}
        cloudColor={colors.whiteGhost}
        skyColor="transparent"
        dispersion={lcgNextRand(flightLCG) * (80 - 40) + 40}
        scale={lcgNextRand(flightLCG) * (200 - 180) + 180}
        style={
          {
            "--moveTo": `${x - 100}%`,
            animationDelay: `-${lcgNextRand(flightLCG) * 6}s`,
            filter: `brightness(${lcgNextRand(flightLCG) * (1 - 0.87) + 0.87})`,
            left: `${x}%`,
          } as CSSProperties
        }
      />
    );
  }

  return (
    <div
      className={`${styles.root} ${className}`}
      data-testid={Flight.name}
      style={{ ...style, "--sky-color": skyColor } as CSSProperties}
      {...rest}
    >
      <Sun className={styles.sun} />
      {clouds}
    </div>
  );
}

An animated flight over clouds with SCSS modules and tsx.


@keyframes cloudMove {
  0% {
    opacity: 0;
    top: 50%;
    transform: translateZ(1px) translate(-50%, -50%) scale(0.4);
  }
  10% {
    opacity: 1;
  }
  90% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    top: 100%;
    transform: translateZ(1px) translate(var(--moveTo), -50%) scale(0.7);
  }
}

.root {
  background-color: var(--sky-color);
  min-height: dimensions.getSize(s512);
  overflow: hidden;
  position: relative;

  &::before {
    background: linear-gradient(
      transparent 35%,
      rgba(colors.$colorWhiteGhost, 0.5) 45%,
      rgba(colors.$colorWhiteGhost, 0.5) 80%,
      colors.$colorWhiteGhost
    );
    content: "";
    height: 100%;
    position: absolute;
    top: 0;
    width: 100%;
  }
}

.sun {
  left: 7%;
  position: absolute;
  top: 7%;
  width: 10%;
}

.cloud {
  animation: cloudMove animation.animationDuration(5) infinite linear;
  position: absolute;
  height: 100%;
  top: 75%;
  transform: translateZ(1px) translate(-50%, -50%);
  width: 100%;
  will-change: transform, opacity, top;
}

The SCSS Modules code for the flying over clouds animation

After several late nights, I had moved the whole site with little to no friction. On local dev, the site was using full SSR, and Next was not complaining at me all the time; I felt like a good little developer. I had validated that it could be done, and the dev experience was great. But what would the impact be on the production site? So, I ran lighthouse tests before and after to see the change on desktop and mobile, and it was relieving.

The lighthouse performance scores for my home page using styled components.
My Home Page Lighthouse scores for Styled Components on desktop).

And then with CSS Modules...

The lighthouse performance scores for my home page using CSS modules.
My Home Page Lighthouse scores for Styled Components on desktop.

You can see that although the performance was already decent, the massive reduction in cumulative layout shift was astounding. I had also shaved a tenth off the largest contentful paint. These scores will help aid Google search results for competitive sites and provide a better experience for the end user.

The whole process has been surprisingly smooth. So much so that I am expecting someone to tell me where my over confidence is about to trip me up. I have now fully gone all in for CSS Modules. One might ask why I avoided Tailwind and the main reason is I find the number of classes on larger projects unwieldy and cumbersome with a poor developer experience for fresh eyes.

Embracing a more traditional CSS approach has been invigorating, and I eagerly anticipate the coming years. Perhaps we will return to the simplicity of pure CSS without SCSS - anything is possible! One thing is certain: as web developers, we must adapt to the ever-changing landscape and continuously strive to create the best experiences for ourselves and those we serve!