modular-css Overview
#Introduction
#What
modular-css implements the best features of the CSS Modules spec and then adds on several extra features to make for a smoother developer experience. It also supports many of the most-popular build tooling proects, is designed for extensibility, and allows for using the entire ecosystem of postcss plugins to customize your CSS.
#Why
CSS Modules has been abandoned as a standard and the current implementation is full of bugs. Attempts to improve that situation have been unsuccessful for years. I also wanted a reason to dive deep with PostCSS, so here we are.
Also because this 👇👇👇
#How
There are a lot of different ways to use modular-css
, pick your favorite!
- Rollup - Tiny bundles, code-splitting, and first-class
modular-css
support. 👌🏻 - Vite - Also tiny bundles, code-splitting, and first-class
modular-css
support, but now with a server! 🎉 - Webpack - Not as full-featured or well-supported as the rollup plugin but works pretty ok.
- Browserify - The old standby. Supports
factor-bundle
for painless CSS bundle splitting! - Svelte - Take your svelte components and power them up using
modular-css
! ⚡ - JS API - The core of
modular-css
, reasonably usable and powers literally everything else. - CLI -
modular-css
via CLI, for those times where you need to try something really quickly. - PostCSS Plugin - Postcss-within-postcss, because sometimes you just need to do a thing. 😵
- Globbing API- Grab
**/*.css
and get a move on. The globbing API is here for you!
#Features
modular-css
implements the best features of the CSS Modules spec and then adds on several extra features to make for a smoother developer experience.
#Selector Scoping
By default all CSS selectors live in the global scope of the page and are chosen based on specificity rules. This has proven to be a model that makes it difficult to succeed and incredibly easy to dig yourself into a hole you can't climb out of. modular-css
scopes all selectors to the local file by default, ensuring that your CSS is always exactly as specific as it should be.
.wooga {
color: red;
}
will be output as
.mcf250d69f_wooga {
color: red;
}
By default the selector scoping is based off hashing the contents of the file but you can also provide your own custom function.
Using these now-mangled selectors would be problematic, if modular-css
didn't give you the tools required to use them easily. When using the browserify transform any require()
calls for CSS files will instead return an object where the keys match the classes/ids defined in the requested CSS file.
var css = require("./styles.css");
// css is:
/*
{
wooga : "mcf250d69f_wooga",
booga : "mcf250d69f_wooga mcf250d69f_booga",
...
}
*/
// So then you can render that class trivially
const html = `<div class="${css.wooga}">Wooga</div>`;
// which then has the properly scoped selector
// <div class="mcf250d69f_wooga">Wooga</div>
// Also easy-to-use with JSX!
const jsx = <div class={css.wooga}>Wooga</div>;
These arrays of selectors can then be applied to elements using the much more nicely-named object keys and you're off to the races.
You can opt out of selector scoping by wrapping your classes/ids in the :global()
pseudo-class, this will prevent them from being renamed but they will still be available in the module's exported object.
:global(.global) {
color: red;
}
when transformed to JS looks like this
var css = require("./styles.css");
// css is:
/*
{
global : "global"
}
*/
Selector scoping is only done on simple classes/ids, any selectors containing tags or pseudo-selectors won't be exported.
:global()
is treated the same as a CSS pseudo-class and therefore cannot wrap multiple comma seperated rules. For example if you're using a CSS reset the following is required:
/* Local Scoped */
ol, ul {
list-style: none;
}
/* Global Scoped (Wrong!) */
:global(ol, ul) {
list-style: none;
}
/* Global Scoped (Correct!) */
:global(ol), :global(ul) {
list-style: none;
}
Adding :global()
to every comma seperated rule would be tedious when using something like Eric Meyer's CSS Reset. Therefore it is recommended that you seperate the reset in to its own file, and make use of the postcss-import module with the after or done hooks to include the file when modular-css has finished processing. You would then need to include @import "reset.css";
somewhere in one of your CSS files.
#Style Composition
Selector limitations mean that it's difficult to use complicated selectors, so to enable building anything of complexity you can compose selectors. These compositions can be within a file, reference global CSS class, or even pull in classes defined in other files.
.composable {
background: black;
}
.local {
composes: composable;
color: red;
}
/* Will be stripped from the CSS output because it doesn't */
/* contain any actual rules */
.removed {
composes: local;
}
When this file is required the JS object will contain the expected keys, but the arrays will now contain more values.
var css = require("./style.css");
// css is:
/*
{
composable : "dafdfcc_composable",
local : "dafdfcc_composable aeacf0c_local",
removed : "dafdfcc_composable aeacf0c_local aeacf0c_removed"
}
*/
Composition also works between files, by providing the source file.
/* === style-guide.css === */
.body {
margin: 10px;
height: 100%;
}
/* === home-page.css === */
.body {
composes: body from "/style-guide.css";
padding: 10px;
}
: Styles can also be composed directly from the global scope to help with interoperability with CSS frameworks or other non-module styles.
.box {
composes: d-flex, px-4, py-3 from global;
color: blue;
}
:
If you're going to be doing a lot of composition with another file you can store the filename into a value for ease of referencing.
/* === style-guide.css === */
.heading {
font-size: 140%;
}
.body {
margin: 10px;
height: 100%;
}
/* === home-page.css === */
@value guide: "/style-guide.css";
.head {
composes: heading from guide;
font-size: 120%;
}
.body {
composes: body from guide;
padding: 10px;
}
#Values
Values are re-usable pieces of content that can be used instead of hardcoding colors, sizes, media queries, or most other forms of CSS values. They're automatically replaced during the build with their defined value, and can also be composed between files for further re-use or overriding. They're effectively static versions of CSS variables, but with a few extra build-time powers we'll get into later.
@value alert: #F00;
@value small: (max-width: 600px);
@media small {
.alert { color: alert; }
}
will output
@media (max-width: 600px) {
.alert { color: #F00; }
}
#Importing @values
@value
declarations can be imported from another file by using a slightly different syntax.
/* === colors.css === */
@value main: red;
@value bg: white;
/* === site.css === */
@value main from "./colors.css";
body {
color: main;
}
It's also possible to import multiple values at once.
/* === colors.css === */
@value main: red;
@value bg: white;
/* === site.css === */
@value main, bg from "./colors.css";
body {
color: main;
background: bg;
}
#Namespaced @values
@value
declarations can be imported as a namespace which provides a convenient shorthand way to access a bunch of shared values from a file.
/* === colors.css === */
@value main: red;
@value bg: white;
/* === site.css === */
@value * as colors from "./colors.css";
body {
color: colors.main;
background: colors.bg;
}
#Wildcard @values
It's possible to import all the @value
definitions from another file into the current one. Any local @value
declarations will override the imported values.
/* === colors.css === */
@value main: red;
@value bg: white;
/* === site.css === */
@value * from "./colors.css";
@value bg: black;
body {
/* black */
background: bg;
/* red */
color: main;
}
Since all files in modular-css
with @value
declaration make that value available to other files it's possible to use the wildcard imports feature to build complex theming systems. When using wildcard imports all the @value
declarations from the source file are re-exported by the file doing the importing.
/* === colors.css === */
@value main: red;
@value bg: white;
/* === mobile-colors.css === */
@value * from "./colors.css";
@value bg: gray;
/* === site.css === */
@value * as colors from "./mobile-colors.css";
body {
/* gray */
background: colors.bg;
/* red */
color: colors.main;
}
#Other Features
These features can help when you find yourself bumping up against the edges of a few specific problems in modular-css
but are best used sparingly.
#Overriding Styles
Sometimes a component will need some customization for use in a specific location/design, but you don't want to bake that customization into the component.:external(...)
helps you solve that problem.
In this case we've got an input
component that is normally 100% of the width of its container, but when it's within the fieldset
component it should only be half as wide.
/* == input.css == */
.input {
width: 100%;
}
/* == fieldset.css == */
.fieldset :external(input from "./input.css") {
width: 50%;
}
will create output like this
.mcd8e24dd1_input {
width: 100%;
}
.mcf250d69f_fieldset .mcd8e24dd1_input {
width: 50%;
}
#Composing Files
When necessary you can also use the @composes
at-rule to enable composing an entire CSS file, instead of going rule-by-rule. This is mostly useful when you've got a base style you want to apply to a component but you need to modify just a single style from the base. Instead of manually creating shadowed versions of all the classes in the base CSS, you can use @composes
to save a bunch of repetition and potential for fat-fingering.
/* == base.css == */
.header {
color: red;
}
.body {
color: blue;
}
/* Imagine 20-30 more styles here... */
/* == custom.css == */
@composes "./base.css";
.title {
composes: header;
background: red;
}
When custom.css
is required the JS object will contain the selectors defined in that file as well as any selectors from base.css
. It also allowed for the .title
class in custom.css
to use composes: header
even though .header
wasn't defined in that file at all.
var css = require("./custom.css");
// css is:
/*
{
// from custom.css
title : "dafdfcc_header aeacf0c_title",
// plus everything from base.css
header : "dafdfcc_header",
body : "dafdfcc_body"
}
*/
There can be only one @composes
declaration per file, just to keep things straightforward. Chaining of files is supported as well, the files will be processed in the correct order based on their dependencies and files at the end of the chain will include all of the rules from every other file they & their dependencies included. This feels like it could get hard to manage quickly, so it's recommended to use @composes
only when necessary and try to avoid reaching for it as the very first solution to a problem!