@radekmie
By Radosław Miernik · Published on · Comment on Meteor Forum, Reddit
We recently started working on a design system in a Meteor app. One of the most important decisions we had to make was deciding on how we are going to style it. Plain CSS or SCSS? CSS-in-JS, and if so, which library? Or Tailwind CSS? All of those options have their ups and downs, and we ended up with…
None of the above. We decided to go with CSS Modules, i.e., plain CSS but with locally-scoped class names. On top of that, we decided to use the CSS Custom Properties, a.k.a. CSS variables1. It all plays well with Figma, so collaboration with our designer is even easier.
Great! Now let’s set it up in our app… There’s nathantreid:css-modules
; let’s give it a try. It works, though it hasn’t been maintained for more than five years now. Normally, it’s not a problem for me, but it depends on really old npm packages, and that’s a compatibility problem with the rest of the app2.
That’s why we’re going to make our own.
Every single piece of the Meteor framework is a package1. It’s “just like” npm packages but with a different set of goals, requirements, and functionalities. You can find the documentation here, but I’ll try to make the next sections self-explanatory enough for you to follow.
First, we need to create a directory where we’ll develop our package. By convention, it has to be in /packages
, so /packages/css-modules
it is. The first file will be package.js
, which is just like package.json
in npm:
;
That’s enough to define our package. It doesn’t do anything, but we can add it as a dependency to our application now:
Every package can provide some code that is available in the application either globally or via imports; nothing fancy, that’s how npm works too. But on top of that, every package can register an arbitrary number of build plugins: linters, compilers, and minifiers; each doing what the name says.
Since our goal is to make the CSS classes available in JavaScript code, we need to generate some JavaScript module based on the CSS one. In other words, we’ll translate the CSS files into a CSS asset and a JSON mapping of the classes.
Let’s register a compiler plugin and add those lines to package.js
:
;
// Every compiler plugin has to rely on this package.
;
In total, our (formatted) package.js
has 15 lines. The number of lines alone is barely ever a problem on its own, but since we’re adding to our application’s infrastructure, I’m on the “the less the better” side.
Our build plugin is set up – now, let’s create the compiler. First, we need to know what we are going to build exactly. Since we’re adding it to an existing application with .css
files in it, we’ve decided to go with the .m.css
extension. All of this code goes to /packages/css-modules/compiler.js
:
,
// Create a compiler instance (we'll define it in a second).
new CSSModulesCompiler,
;
And here’s our compiler:
;
We’re building on top of the Core caching-compiler
package. You can read in its documentation how the cache works, but it’s a default for all compilers. We need to implement a few functions, so let’s do that:
// We're adding new methods.
// Decoupled logic of extracting metadata out of `file`.
All of the above wiring integrates our compiler with the Meteor build system and the caching-compiler
. Yes, it’s kind of a boilerplate, but that’s inevitable in any build tool I worked with (Parcel, Webpack, Vite).
In the previous sections, we configured everything needed for our compiler. The only missing thing is the actual compilation, so let’s do that:
;
;
;
;
;
// We're adding a new method.
In short, we read the .m.css
file contents and use PostCSS with the postcss-modules
plugin on it. When compiling for production, minify it using Meteor’s standard minifier-css
. Then, create a JavaScript snippet that registers the stylesheet using the modules
Meteor package and exports the CSS class map. It may feel super dense, but that’s common with glue code, I think.
You may noticed we’re using some npm packages here: postcss
and postcss-modules
. We could add them to the package’s npm dependencies (see Npm.depends
), but that separates the installation from the other packages (that’s how Meteor solves it). Instead, we decided to add them to the app’s dependencies instead (i.e., app’s package.json
).
The compiler is now ready – let’s use it! Let’s create an example Paragraph
component in our design system. First, the styles in Paragraph.m.css
:
}
Next, Paragraph.js
:
;
And that’s it! Welcome, my dear _e0e35c5c
.
If you use the above code, you will see that changing the .m.css
files will trigger a page reload. That’s not ideal for the developers since it makes the development longer. But Meteor supports hot module replacement, doesn’t it?
It has been since Meteor 2.0 (January 2021), but that requires a little work on our end. Let’s change our JavaScript snippet a little:
-require('meteor/modules').addStyles(${JSON.stringify(css)});
+const style =
+ require('meteor/modules').addStyles(${JSON.stringify(css)});
+if (module.hot) {
+ module.hot.accept();
+ module.hot.dispose(() => {
+ style.remove();
+ });
+}
+
export default ${JSON.stringify(classMap)};
The most important part is the module.hot.accept()
. That marks this module as “replaceable” and thus not requiring a full page reload. Additionally, we have to clean up the previous execution, i.e., remove the previous <style>
tag.
Of course, the above code could be eliminated in the production bundle, but we decided to skip that for now. On top of that, we could somehow make it part of the CSS bundle instead of the JavaScript bundle, but I didn’t have time to look into that (file.addStylesheet
looks like it, but then it’s not lazy
).
All of the above snippets are not using TypeScript. That’s bad since we’d like all of our codebase to be typed! The problem is there are no typings for this part of Meteor yet, and we just agreed to make an exception.
However, we don’t want to @ts-expect-error
every single .m.css
import in our React components! Luckily, TypeScript has us covered with its wildcard module declarations. Let’s create a /types/css-modules.d.ts
:
That’s not ideal since we can have a typo in our class name, but that’s better than nothing. To make it better, we could generate typings based on the .m.css
files and put them somewhere where TypeScript could find them2.
Since we’re calling PostCSS ourselves, we can easily create our own plugins. We actually had to make one since the CSS Custom Properties do not work in CSS Media Queries. And just like Meteor plugins, PostCSS plugins are simple!
// Map of custom media queries to their CSS counterparts.
;
// Cached getter and regex.
;
;
/** @type {import('postcss').AcceptedPlugin} */
;
// Remember to add `postcssCustomMediaPlugin` to the `postcss` instance!
This simple plugin allows us to write the following code:
{
}
}
And if you need other plugins, see www.PostCSS.parts for more. Remember to use autoprefixer
too – it can save you a few bug reports!
Creating plugins is a fun way of learning that inevitably forces you to get a deeper understanding of the tools you work with. And whether it’s something you see for the first time (like me with the PostCSS above) or something you have worked with for years (Meteor, yay!) – it’s great on all fronts.
I wanted to show you that custom integrations are not necessarily complex or huge – we managed to keep those two plugins in 122 (formatted) lines. It shouldn’t be a problem to maintain them too since we have control over everything (maybe except for postcss-modules
, but it’s not big either).
Finally, you may ask why - won’t I make it into a package? The answer is simple: I don’t have the capacity to maintain it. Ah, and I’d also need to document it, add tests, update dependencies once in a while… And come up with an interface for configuring plugins since it’s hardcoded right now.
Now I’ll continue working on the plugin for the next month…
I didn’t check for the browser support of CSS Custom Properties recently and was surprised to see that it’s available in all maintained browsers. For more than six years, actually! That’s what I get for reading only about new features and not the stabilization of the standardized ones. Well, almost standardized.
The biggest issue was the supported Browserslist queries.
There are tools on top of that, but I feel confident generalizing here.
We could put them next to the .m.css
files, but ideally, it’d be hidden from the user. An alternative would be to create symlinks, just like zodern:types
.