We will use Webpack and PostCSS to implement CSS modules.
Note
If you get lost, you can check how my-v1-app is set up.
You will need these dependencies to build an Embroider app with Webpack.
@embroider/compat@embroider/core@embroider/webpackwebpack
For PostCSS, here is what you likely need at minimum.
autoprefixerpostcsspostcss-loader
Finally, some packages to improve your developer experience (DX).
All in all, here's a one-line command for installation:
pnpm install --dev \
@embroider/compat @embroider/core @embroider/webpack webpack \
autoprefixer postcss postcss-loader \
embroider-css-modules type-css-modules1. Needed only if you have a TypeScript project.
In this step, you will update two files: ember-cli-build.js and postcss.config.js.
If you have a new Ember app, you can copy-paste the starter code for ember-cli-build.js. The code defines a variable called options, which you will update later.
Starter code for ember-cli-build.js
You may remove the ember-cli-babel option if your project doesn't support TypeScript.
'use strict';
const { Webpack } = require('@embroider/webpack');
const EmberApp = require('ember-cli/lib/broccoli/ember-app');
function isProduction() {
return EmberApp.env() === 'production';
}
module.exports = function (defaults) {
const app = new EmberApp(defaults, {
// Add options here
'ember-cli-babel': {
enableTypeScriptTransform: true,
},
});
const options = {
skipBabel: [
{
package: 'qunit',
},
],
};
return require('@embroider/compat').compatBuild(app, Webpack, options);
};Note
Even if you already have an Embroider app, please do compare your ember-cli-build.js to the starter code so that we are on the same page.
You'll need to set these Webpack options: cssLoaderOptions, publicAssetURL, and webpackConfig. You can do so by adding a key named packagerOptions to options.
options variable
const options = {
packagerOptions: {
cssLoaderOptions: {
modules: {
localIdentName: isProduction()
? '[sha512:hash:base64:5]'
: '[path][name]__[local]',
mode: (resourcePath) => {
const hostAppLocation = 'node_modules/.embroider/rewritten-app';
return resourcePath.includes(hostAppLocation) ? 'local' : 'global';
},
},
sourceMap: !isProduction(),
},
publicAssetURL: '/',
webpackConfig: {
module: {
rules: [
{
test: /(node_modules\/\.embroider\/rewritten-app\/)(.*\.css)$/i,
use: [
{
loader: 'postcss-loader',
options: {
sourceMap: !isProduction(),
postcssOptions: {
config: './postcss.config.js',
},
},
},
],
},
/*
Uncomment this rule to load asset files, e.g. fonts, icons, etc.
See https://webpack.js.org/guides/asset-modules/ for more information.
*/
// {
// test: /(node_modules\/\.embroider\/rewritten-app\/)(.*\.(ttf|woff))$/,
// type: 'asset/resource',
// },
],
},
},
},
skipBabel: [
{
package: 'qunit',
},
],
};The most important part is cssLoaderOptions.modules.mode. It helps Webpack decide if a CSS file comes from your app (local) or "outside" (global).
function mode(resourcePath) {
const hostAppLocation = 'node_modules/.embroider/rewritten-app';
return resourcePath.includes(hostAppLocation) ? 'local' : 'global';
}Important
If your app lives in a monorepo, please include the relative path from the workspace root to the app. This way, Webpack can distinguish CSS files from your app (local) from those from an addon in the monorepo (global).
// If your app is located at `docs-app`
const hostAppLocation = 'docs-app/node_modules/.embroider/rewritten-app';Webpack supports PostCSS. Create the file postcss.config.js, then list the PostCSS plugins that you need (e.g. autoprefixer).
# From the project root
touch postcss.config.jsconst env = process.env.EMBER_ENV ?? 'development';
const plugins = [require('autoprefixer')];
if (env === 'production') {
// plugins.push(...);
}
module.exports = {
plugins,
};Use eslint-plugin-n?
In .eslintrc.js, find the override rule for Node files. Add postcss.config.js to the list of files.
'use strict';
module.exports = {
overrides: [
// Node files
{
files: [
'./postcss.config.js',
// ...
],
extends: ['plugin:n/recommended'],
},
],
};To ensure the load order with Webpack, you will now import app/styles/app.css (which defines global styles, @import, @font-face, etc.) in app/app.ts.
Unfortunately, we can't import CSS files located in app/styles, so you'll need to move app.css somewhere else. To do so, let's create the folder app/assets.
mkdir app/assets
cp app/styles/app.css app/assets/app.cssImportant
Ember expects app/styles/app.css to exist. Instead of deleting the file, leave it empty. You can copy-paste this default code from Ember CLI:
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */Finally, import app.css in app/app.ts.
app/app.ts
+ import './assets/app.css';
+
import Application from '@ember/application';
import loadInitializers from 'ember-load-initializers';
import Resolver from 'ember-resolver';
import config from './config/environment';
export default class App extends Application {
modulePrefix = config.modulePrefix;
podModulePrefix = config.podModulePrefix;
Resolver = Resolver;
}
loadInitializers(App, config.modulePrefix);You can style your app now. Let's create a Glimmer component to test CSS modules.
ember g component hello -gcWhile Ember CLI can create the template and the backing class, you will need to manually create the stylesheet.
# From the project root
touch app/components/hello.cssThe goal is to display Hello world! in a <div>-container. In the stylesheet, define the class selector .container.
app/components/hello.css
.container {
color: magenta;
font-family: monospace;
font-size: 1.5rem;
font-weight: 500;
padding: 1rem;
}Next, in the backing class, import the stylesheet and name it styles. Store styles as a class property so that the template has access.
app/components/hello.ts
Note, we write the file extension .css explicitly.
import Component from '@glimmer/component';
import styles from './hello.css';
export default class Hello extends Component {
styles = styles;
}Display the message and style the container.
app/components/hello.hbs
Finally, render the component. Et voilà! ✨
app/templates/index.hbs
Note
Use the {{local}} helper to apply multiple styles.
Since we pass styles to the template as a class property, it's not possible to style template-only components. (Note, template-only components have the import path @ember/component/template-only.)
We can address this issue by using <template> tag. Replace hello.{hbs,ts} with hello.gts:
app/components/hello.gts
import styles from './hello.css';
<template>
<div class={{styles.container}}>
Hello world!
</div>
</template>To help TypeScript understand what it means to import a CSS file,
import styles from './hello.css';and what styles looks like, you will need to provide the declaration file hello.css.d.ts.
Lucky for you, type-css-modules can create this file. Write a pre-script as shown below:
/* package.json */
{
"scripts": {
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\"",
"prelint:types": "type-css-modules --src app",
"lint:types": "ember-tsc --noEmit" // or "glint"
}
}Now, when you run lint, the prelint:types script will create the CSS declaration files, then lint:types will type-check the files in your project.
pnpm lintAt any time, you can run prelint:types to only create the CSS declaration files.
pnpm prelint:typesA component's template and backing class must have the same name (the related technical terms are resolve and resolution):
hello.{hbs,ts}with the flat component structurehello/index.{hbs,ts}with the nested component structure
In contrast, the component's stylesheet can have a different name and even live in a different folder. This is because we explicitly import the CSS file in the backing class.
Still, for everyone's sanity, I recommend colocating the stylesheet and providing the same name.
# Flat component structure
your-ember-app
├── app
│ └── components
│ ├── hello.css
│ ├── hello.css.d.ts
│ ├── hello.hbs
│ └── hello.ts
...# Nested component structure
your-ember-app
├── app
│ └── components
│ └── hello
│ ├── index.css
│ ├── index.css.d.ts
│ ├── index.hbs
│ └── index.ts
...Yes! You can use *.module.css to indicate the stylesheets that are for CSS modules. type-css-modules will create declaration files with the extension *.module.css.d.ts.
- import styles from './hello.css';
+ import styles from './hello.module.css';Note
The files app/assets/app.css and app/styles/app.css keep the extension *.css.
In general, I recommend not writing an hasClass() assertion to test styles.
The presence (or absence) of a class doesn't guarantee that what your user sees is correct and will be in the future. An hasStyle() assertion is somewhat better (the assertion is stronger), but may fail due to rounding errors. In general, prefer writing visual regression tests. This helps you hide implementation details.
That said, if you must write an hasClass assertion, you can get the global class name by importing the stylesheet.
tests/integration/components/hello-test.ts
For simplicity, other import statements have been hidden.
import styles from 'your-ember-app/components/hello.css';
module('Integration | Component | hello', function (hooks) {
setupRenderingTest(hooks);
test('it renders', async function (assert) {
await render(hbs`
<Hello />
`);
assert.dom('div').hasClass(styles.container);
});
});To style a route, apply the ideas that you learned for components.
- Import a stylesheet in the backing class (the controller) and name it
styles. - Pass
stylesto the template as a class property. - Write
this.stylesin the template.
You can avoid controllers by using <template> tag. If your ember-source version is below 6.3, you will need to install ember-route-template.
app/templates/index.gts
import Hello from 'your-ember-app/components/hello';
import styles from './index.css';
<template>
<div class={{styles.container}}>
<Hello />
</div>
</template>A route's template and backing class must have the same name:
app/controllers/index.tsapp/templates/index.hbs
In contrast, the route's stylesheet can have a different name and be placed in any folder (besides app/styles). Again, this is because we explicitly import the CSS file in the backing class.
For proximity, I recommend colocating the stylesheet and the controller. Do provide the same name.
your-ember-app
├── app
│ ├── controllers
│ │ ├── index.css
│ │ ├── index.css.d.ts
│ │ └── index.ts
│ │
│ └── templates
│ └── index.hbs
...With <template> tag, you can colocate the stylesheet and the route template.
your-ember-app
├── app
│ └── templates
│ ├── index.css
│ ├── index.css.d.ts
│ └── index.gts
...