Making CSS Modules Sassy!


Making CSS Modules Sassy!

I have been working with many different style approaches and patterns over my years of web development. It always intrigues me how often, after several years, I find the need to simplify how I apply style to web pages, and how I find myself looking longingly at earlier versions of CSS, when the web was a simpler place. Perhaps I am getting old (spoiler: I am), but I take heart that HTML and CSS were built on fundamentally simple principles that encouraged progressive enhancement and device adaptability.

When I first started using SCSS well over a decade ago, I remember thinking, "This is CSS, and so much more." Of course, SCSS compiles into CSS, but the options it offered in the early 2010s included mathematical operations, arrays, variables, functions, and mixins, to name a few that CSS simply could not provide.

Slowly, over the last few years, however, CSS has really made an effort at catching up:

  • 2012: CSS calc() function becomes supported in most browsers
  • 2016: CSS Properties (variables) gained support in most browsers
  • 2020: The min(), max() and clamp() features allow more mathematical operations
  • 2023: Nested styling simplifies the layout of styling code
  • 2024: @Property allows us to enable interpolation and animation of CSS variables
  • Coming soon: CSS function, mixins and conditional logic

I wrote a previous article about migrating from Styled Components to SCSS modules, and I still love SCSS and find it immensely useful in the right scenarios. But the web is ever-evolving, and SCSS is starting to show early signs that it might be time for us to move on and explore other alternatives. At Jagex, my fellow web engineer, BenIcon to represent this opens in a new tab, had started investigating an upgrade to Next16 and the use of TurbopackIcon to represent this opens in a new tab for our current projects.

Turbopack is Vercel's incremental bundler optimised for JavaScript and TypeScript, written in Rust, and integrated into Next.js. It offers faster development experiences, and coming from the days of Grunt, I can genuinely appreciate this. Currently, Turbopack supports SCSS with only the standard feature set and not areas such as ICSSIcon to represent this opens in a new tab. We use ICSS to expose CSS variables to our React/Next app and to render documentation about them in our Storybook Design system and make them available to JS and server-side code at build time.

@use "./dimensions";
@use "./helpers/_json";

:export {
  globalInputMaxWidth: dimensions.$globalInputMaxWidth;
  globalTextMaxWidth: dimensions.$globalTextMaxWidth;
  globalContentMaxWidth: dimensions.$globalContentMaxWidth;
  globalDecorationMaxWidth: dimensions.$globalDecorationMaxWidth;
  breakpoints: json.json-stringify(dimensions.$breakpoints);
  sizes: json.json-stringify(dimensions.$sizes);
}

A scss file exporting vars for use in TypeScript

So, with all the above in mind, it was time to make a call. It can be tempting to sit on what you know in your comfort zone, but ultimately, Ben put a strong case forward, and we ditched SCSS for CSS modules. This was not a simple case of just renaming some files and changing some syntax; there were still some problems to resolve...

Sharing variables

If we could not import CSS values into TypeScript (other than class names), we had a problem. We could, of course, hard-code the variable names in the design system and then set the CSS property values at runtime, but this would not enable dynamic documentation or unit testing.

The RuneScape trade UI showing one side offering CSS3 and the other TypeScript

The only plausible solution here was a great call from Ben to define the variables in TypeScript and then use a tool to generate a CSS file containing those variables for global inclusion. This had the advantage that by defining the values in TypeScript, we could set appropriate types and then easily import them where needed.

export const colors: Record<
  string,
  Exclude<CSSProperties["color"], undefined>
> = {
  blackEvil: "#000",
  greyLight: "#656565",
  greyDark: "#222425",
};

Colour tokens defined in TypeScript to be generated into css properties

The script runs pre-build for the site and the design system, meaning that the dev experience is not impacted, and IDEs can understand where the CSS properties are defined.

But Breakpoints?

Whilst CSS Variables allow us to share properties such as height, colour, and animations, they cannot be used in media queries. This is because CSS custom properties are resolved after media queries are evaluated, and media queries must be decided before the cascade and computed values exist. Now, there are proposals for CSS environment variables and query-specific variables, but these are only proposals at this time.

So enter PostCSSIcon to represent this opens in a new tab - a transformation engine that facilitates the transformation, generation and analysis of CSS. So whilst query-specific variables may not work in native CSS, we can use PostCSS to transform them into standard media query values.

@custom-media --breakpoint-small (min-width: 768px);

.element {
	display: block;
}

@media (--breakpoint-small) {
  .element {
    display: none;
  }
}

/* becomes */

.element {
	display: block;
}

@media (min-width: 768px) {
  .element {
    display: none;
  }
}

Setting query-specific variables in css and transforming them with PostCSS

We can generate the query-specific variables from a TypeScript file and then just use the --var-name in the media query to keep the values consistent.

PostCSS also allows us to parse the CSS pixel values and convert them to rems, simplifying the developer experience whilst maintaining UI scalability.

No more mixins

CSS modules offer the composes attribute to include classes inside others, even from external files. Meaning re-usable properties can be defined in a CSS file and then included in multiple places.

/*button-base.css*/
.button-base {
	appearance: none;
	background: green;
	border: none;
}

/*buttons.css*/

.primary-button {
	composes: button-base from "../button-base.css";
	
	height: 40px;
}

Using composes

And on the seventh day, Ben created a 300+ file PR after all the refactoring and new additions, but now we are closer to being back to native CSS reducing the developer on-boarding cost, and removing our dependency on preprocessors. In the next year we may not even need PostCSS if query-specific variables gain support and a few more CSS features enter the spec!

Links