Exploring Ember Polaris: CSS Modules Without Addons

This post builds on my post on Ember's fresh take on the component format, which showed how you can use normal JavaScript imports in Ember's .gjs format.

In this post, we'll see how to take advantage of the .gjs format to use standard CSS modules in your Ember app without an addon.


It's true that the .gjs format lets you put normal JavaScript code and your template in the same file, allowing you to access JavaScript variables in your templates.

But does that mean that the import syntax has the same capabilities as the import syntax in other frameworks?

The short answer is yes.

The longer answer is that the web framework ecosystem has slowly evolved the meaning of the import syntax. These days, people expect imports written in a front-end app to resolve according to Node module resolution.

🕵 Node Module Resolution... In Front-End Apps?

For the longest time, "node module resolution" didn't have a consistent meaning for front-end apps.

Browserify had subtly different behavior than Webpack, and the behavior changed subtly with each new version of Webpack.

But good news!

In 2024, "node module resolution" is specified well enough to have a reasonably consistent implementation across front-end bundlers.

There's still a lot of growing pains, but at least we can characterize most of the discrepancies between bundlers as bugs rather than different implementations of unspecified behavior.

It's nice that we've standardized on node module resolution, but it's gone further than that. With the evolution of webpack loaders, the front-end ecosystem evolved ways of importing a number of different kinds of assets, like TypeScript, JSON, CSS, SVG and even WebAssembly.

import init from './example.wasm?init'

init().then((instance) => {
  instance.exports.test()
})

An example of importing WebAssembly files from JSON (source)

Over time, a lot of these webpack loaders became really popular, and many of the most popular loaders are documented on the Webpack website.

Some of the most popular of these loaders effectively became de-facto standards when they were included by default in Vite.

Vite is a blazing fast development environment for applications written using standard modules. It's so good that almost every major framework plans to build the next major version of their tools on Vite. Including Ember.


📓
When you can import a format by default in Vite and it's available as a documented Webpack loader, it's a de-facto ecosystem standard.

There's some subtlety here. While Vite ships a specific version of the loader by default, Webpack loaders offer a lot of configuration options. In my opinion, the default Vite behavior is the de-facto standard as long as there's an easy way to configure a Webpack loader to behave the same way.

It's a Big Deal for Ember Apps

We've already said that you can use standard JavaScript imports in your .gjs components. But it goes further than that.

Because of an under-the-hood revolution in the Ember build tools, Ember apps can easily import modules using the de-facto ecosystem standards.

  • CSS modules? Check.
  • SVG files? Check.
  • WebAssembly? Why not?

But how? The first thing to know is that the Ember project is actively working on making Vite the default build tool for Ember apps. If you can use Vite on your project, these features work because they come by default in Vite.

Otherwise, you can configure the built-in Webpack packager to enable the Vite behavior using a popular Webpack loader. When Vite becomes the default build tool for Ember apps, you can just remove the Webpack configuration and everything will continue to work.

🕵 Which Ember do I need?

In order to configure the built-in Webpack loader, you'll need to use the latest version of Embroider, which works as far back as Ember 3.28 or higher (the vast majority of all Ember apps).

If your app is still using a classic build (you can tell that you're using Embroider if your ember-cli-build.js file has @embroider/webpack in it), then you'll first need to migrate to Embroider.

As long as you're on 3.28 or higher, you can do this without upgrading your version of Ember. If you follow the instructions linked above, it'll probably be pretty compatible.

You'll gain the ability to configure the built-in Webpack loader as a bonus. You'll also unlock the path to even cooler Ember features in the future, like built-in Vite support.

If you're still using the classic Ember build system, now's the time to try Embroider!

That's what we're going to do with CSS modules today. Since Vite ships CSS modules by default, and since Webpack has a popular, well-documented loader that supports CSS modules, we can easily add CSS modules to Ember apps today. When your app migrates to Vite, you can use Vite's first-party support instead.

CSS Modules in Ember. Without an Addon.

The technique we'll explore here takes advantage of the fact that modern Ember components can access imported variables in templates by using the <template> syntax in JavaScript and TypeScript files.

The authoring format is pretty straight-forward. We write the CSS module itself in the standard format (css-modules/css-modules).

.article {
  background-color: #ccc; 
}

app/components/my-component.module.css

import styles from "./my-component.module.css";
 
<template>
  <article class="my-article {{styles.article}}">
    {{yield}}
  </article>
</template>

app/components/my-component.gts

💡
When you import a CSS module from a JavaScript module it turns into a single default export with a property for each top-level class you can use as an HTML class.

We simply import the styles from the CSS module, and interpolate them into our article's class attribute.

Making it Work Today

When full support for Vite lands in Ember, that's all there is to it. We're getting there, but most Ember users reading this post today aren't using Vite yet.

So let's get our example working in a stable Ember app that uses Embroider.

   return require('@embroider/compat').compatBuild(app, Webpack, {
+    packagerOptions: {
+      cssLoaderOptions: {
+        modules: {
+          auto: true,
+        },
+      },
+    },
     // ...
  });

ember-cli-build.js

ℹ️
If your ember-cli-build.js uses Embroider (and contains a line that look like return require('embroider/compat').compatBuild(...), then you can configure the settings in Embroider's built-in instance of css-loader this way.

This change enables automatic CSS modules in Webpack's css-loader (docs). The automatic behavior enables CSS modules in files that end in .module.css.

And that's all you need to do to get basic support for CSS modules in Glimmer.

Going Further: Smoothing Out Ergonomic Rough Edges

In practice, there are other things you'll want to do to get the best developer ergonomics for CSS modules. The embroider-css-modules repository documents a number of additional things you can do to smooth out the experience.

Vite will ultimately handle some of this for you, and things will be really smooth by the time we're done polishing the Polaris Edition.

For now, you'll have to do some work yourself to smooth out some rough edges, but the documentation in embroider-css-modules builds on the concepts in this blog post. This includes assuming that Vite defines the authoring format and using Webpack to polyfill Vite's default behavior.

A Sign of Things to Come

This example is cool because it demonstrates how Ember's Polaris strategy lets Ember apps take advantage of emerging ecosystem standards without any special Emberisms.

We saw how you can use Webpack as a polyfill for Ember's Vite future. Using Webpack in this way lets you adopt the future without giving up on Ember's strong belief in smooth evolution, which makes Ember apps such a pleasure to maintain over the long haul.

Perhaps the most interesting thing of all: the approach in this post works today in Ember apps running on stable versions of Ember.

If you've gotten this far, you're the kind of person who would probably have fun playing around with CSS modules in an Ember app.

Have fun! And let's work together to build a better future for all of us.