We will use Webpack and PostCSS to implement CSS modules.
Note
If you get lost, you can check how my-app
is set up.
You will need these dependencies to build an Embroider app with Webpack.
@embroider/compat
@embroider/core
@embroider/webpack
webpack
For PostCSS, here is what you likely need at minimum.
autoprefixer
postcss
postcss-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-modules
1. 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.js
const 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.css
Important
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 -gc
While 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.css
The 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 HelloComponent 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 \"npm:lint:*(!fix)\" --names \"lint:\"",
"prelint:types": "type-css-modules --src app",
"lint:types": "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 lint
At any time, you can run prelint:types
to only create the CSS declaration files.
pnpm prelint:types
A 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
styles
to the template as a class property. - Write
this.styles
in the template.
If you want to avoid controllers, you can use ember-route-template
(experimental) and pass styles
to the template directly.
app/templates/index.gts
import Route from 'ember-route-template';
import Hello from '../components/hello';
import styles from './index.css';
export default Route(
<template>
<div class={{styles.container}}>
<Hello />
</div>
</template>,
);
A route's template and backing class must have the same name:
app/controllers/index.ts
app/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 ember-route-template
, you may colocate the stylesheet and the route template.
your-ember-app
├── app
│ └── templates
│ ├── index.css
│ ├── index.css.d.ts
│ └── index.gts
...