modular-css v28.2.0

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 👇👇👇

Green pills look gross

#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.

Open in the REPL ▶️
.wooga { color: red; }

will be output as

Open in the REPL ▶️
.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.

Open in the REPL ▶️
: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:

Open in the REPL ▶️
/* 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.

Open in the REPL ▶️
.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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
.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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
@value alert: #F00; @value small: (max-width: 600px); @media small { .alert { color: alert; } }

will output

Open in the REPL ▶️
@media (max-width: 600px) { .alert { color: #F00; } }

#Importing @values

@value declarations can be imported from another file by using a slightly different syntax.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
/* === 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.

Open in the REPL ▶️
/* == input.css == */ .input { width: 100%; } /* == fieldset.css == */ .fieldset :external(input from "./input.css") { width: 50%; }

will create output like this

Open in the REPL ▶️
.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.

Open in the REPL ▶️
/* == 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!