DEV Community

Alex Ma
Alex Ma

Posted on

Using the PostCSS plugin Let your WebApp support dark mode

image

Recently my company needed to add multiple skin functions to multiple WebApps (about 20 +) . The default was white skin, so we started with dark mode to gradually achieve multiple skin functions. This article is a record of the implementation ideas.

Solution for skin-changing

css variables

css variables is the Web standard that implements support for dark patterns,

the following code queries through the CSS Media, the simplest implementation.

:root {
    color-scheme: light dark;
    background: white;
    color: black;
}

@media (prefers-color-scheme: dark) {
    :root {
        background: black;
        color: white;
    }
}
Enter fullscreen mode Exit fullscreen mode

Use CSS variables if you have a lot of colors

:root {
    color-scheme: light dark;
    --nav-bg-color: #F7F7F7;
    --content-bg-color: #FFFFFF;
    --font-color: rgba(0,0,0,.9);
}

@media (prefers-color-scheme: dark) {
    :root {
        --nav-bg-color: #2F2F2F;
        --content-bg-color: #2C2C2C;
        --font-color: rgba(255, 255, 255, .8);
    }
}

:root {
    color: var(--font-color)
}

.header {
    background-color: var(--nav-bg-color);
}

.content {
    background-color: var(--content-bg-color);
}
Enter fullscreen mode Exit fullscreen mode

Advantages: the least amount of code, easy to implement;

The downside: There’s a Cross-browser, which is supported by Edge16 + ; older projects are implemented, and CSS needs to be refactored, so it’s not going to work for us, and if it’s a new WebApp, I wouldn’t hesitate to use it.

Online compilation use less.js

The most typical example of this scenario is the https://antdtheme.com/ , via the less modifyVars method

Enables run-time changes to fewer variables. When called with a new value, fewer files are recompiled without reloading.

<script src="less.js"></script>
<script>
less.modifyVars({ '@text-color': '#fff', '@bg-color': '#000' });
</script>
Enter fullscreen mode Exit fullscreen mode

If there are too many color variables to change, or too many style files, it will cause Cottonwood to switch.

Build multiple CSS

Of course, you can also manually build 2 CSS styles

var less = require("less");
var fs = require("fs");

fs.readFile("./index.less", "utf-8", (err, str) => {
  less.render(
    str,
    {
      paths: [".", "./components"], //  the search path for the@import directive
      compress: true, 
      modifyVars: {
        "@text-color": "#fff",
        "@bg-color": "#000",
      },
    },
    function (e, output) {
      console.log(output.css);
    }
  );
});
Enter fullscreen mode Exit fullscreen mode

So you can skin it by dynamically inserting CSS

function changeTheme(theme) {
    const styleCss = document.querySelector("#styleCss");
    if (styleCss) {
        styleCss.href = `/assets/css/${theme}.css`;
    } else {
        const head = document.getElementsByTagName("head")[0];
        const link = document.createElement("link");
        link.id = "styleCss";
        link.type = "text/css";
        link.rel = "stylesheet";
        link.dataset.type = "theme";
        link.href = `/assets/css/${theme}.css`;
        head.appendChild(link);   
    }
    localStorage.setItem("theme", theme);
}
Enter fullscreen mode Exit fullscreen mode

One problem with this approach is that it causes the entire page to reorder when you click to switch, so we need to separate out the color-only style files. From this point of view, we are exposed to PostCSS.

PostCSS

The PostCSS core contains a parser that generates a CSS AST (Abstract Syntax Tree), which is a representation of a node tree that parses strings of CSS. When we change something inside the CSS Abstract Syntax Tree, PostCSS will still represent it as a root node but stringify the syntax tree back into a CSS string.

The core process is ** Parse->Transform--> Generate ** Is it like Babel ?

Everyone knows that https://astexplorer.net/ is a site that can be used to write Babel plugins, but have you used any other parsers? Select CSS and PostCSS here so you can parse your CSS into your CSS AST (abstract syntax tree) .

image astexplorer

Purpose

less file image

Currently I have one less style and two color variables, I need to generate the following style:

output image

So I can add and remove the ‘dark’ class in the HTML root node to do this.

Some of you may be asking, Suddenly, why is it Less? Can PostCSS parse Less? The answer is no.

At the moment, I'am sure your webapp is based on Webpack.

module: {
    rules:[
        //...
        {
           test: /\.less$/i,
           use: ['style-loader', 'css-loader', 'postcss-loader', 'less-loader'],
        },
        //...
    ]
}
Enter fullscreen mode Exit fullscreen mode

Webpacks loader's order of execution is from right to left, Less pass by less-loader,It becomes CSS

Start writing a PostCSS plugin

We can use postcss-plugin-boilerplate , This scaffolding to create a postcss-plugin ,It also has jest unit tests configured。You can create one postcss-plugin project with a few simple commands。

image

Of course we can just create a JS file in the project root directory

// test-plugin.js
var postcss = require("postcss");

module.exports = postcss.plugin("pluginname", function (opts) {
  opts = opts || {}; // plugin 参数
  return function (root, result) {
    // Transform the CSS AST
  };
});

Enter fullscreen mode Exit fullscreen mode

And then, after that, Just bring it in postcss.config.js

module.exports = {
  plugins: [
    require('./test-plugin'),
    require('autoprefixer')
  ]
};
Enter fullscreen mode Exit fullscreen mode

PostCSS plugin Hello world

Write a plugin that inverts CSS property values

var postcss = require("postcss");

module.exports = postcss.plugin("postcss-backwards", function (opts) {
  opts = opts || {};
  return function (root, result) {
    // Iterate over all style nodes
    root.walkDecls((declaration) => {
      declaration.value = declaration.value.split("").reverse().join("");
    });
  };
});
Enter fullscreen mode Exit fullscreen mode

Of course this plugin, doesn’t make any sense, we just use it to learn how to write PostCSS plugin

postcss-multiple-themes

Usage

JS entry file import 2 style files

import "./default-theme.less";
import "./dark-theme.less";
Enter fullscreen mode Exit fullscreen mode

component.less

.box{
  width: 100px;
  height: 100px;
  border: 1px solid @border;
  background-color: @bg;
  color: @color;
}
Enter fullscreen mode Exit fullscreen mode

default-theme.less

@import "./component";

@border: #333;
@color: #000;
@bg: #fff;
Enter fullscreen mode Exit fullscreen mode

dark-theme.less

@import "./component";

@border: #999;
@color: #fff;
@bg: #000;
Enter fullscreen mode Exit fullscreen mode

Output css

.box {
  width: 100px;
  height: 100px;
  border: 1px solid #333;
  background-color: #fff;
  color: #000;
}
.dark .box {
  border: 1px solid #999;
  background-color: #000;
  color: #fff;
}
Enter fullscreen mode Exit fullscreen mode

Source Code

function isEmpty(arr) {
  return Array.isArray(arr) && arr.length === 0;
}

const hasColorProp = (colorProps, declProp) =>
  colorProps.some((prop) => declProp.includes(prop));

module.exports = (opts = {}) => {
  if (!opts.colorProps) {
    opts.colorProps = ["color", "background", "border", "box-shadow", "stroke"];
  }
  return (root) => {
    let theme;
    const file = root.source.input.file || "";

    const matched = file.match(
      /(?<theme>[a-zA-Z0-9]+)-theme.(less|css|scss|sass)/
    );
    if (matched && matched.groups.theme !== "default") {
      theme = matched.groups.theme;
    } else {
      if (process.env.NODE_ENV == "test") {
        theme = "test";
      }
    }
    if (theme) {
      root.walkRules((rule) => {
        rule.walkDecls((decl) => {
          if (!hasColorProp(opts.colorProps, decl.prop)) {
            decl.remove();
          }
        });

        if (isEmpty(rule.nodes)) {
          rule.remove();
        } else {
          rule.selector = rule.selector
            .replace(/\n/g, "")
            .split(",")
            .map((s) => `.${theme} ${s}`)
            .join(",\n");
        }
      });
    }
  };
};
Enter fullscreen mode Exit fullscreen mode

Implementation steps

1、Use the file name to determine if a skin style needs to be generated

const file = root.source.input.file || "";

const matched = file.match(
  /(?<theme>[a-zA-Z0-9]+)-theme.(less|css|scss|sass)/
);
Enter fullscreen mode Exit fullscreen mode

2、Remove styles that do not contain colors, and leave border-color background-color and the CSS properties that contains colors

["color", "background","border","box-shadow","stroke",]

3、If there are no CSS properties in the CSS selector, delete the selector

4、In front of the CSS selector .theme class name

Upgrade of old project

The original project may not have color-sensitive variables in a separate style file, and the absolute value of color may be written in the style.

Is it possible to write a tool to help us upgrade?

At this time, I have a library that helps me,postcss-less will help us parse the less to AST ,Then we can configure the rules to replace the ** color with the variable **

configure the rules

module.exports = [
  {
    prop: ["background-color", "background"],
    from: ["#fff", "#ffffff", "@white"],
    to: "@component-background",
  },
  {
    prop: ["border", "border-color"],
    from: ["#D3D9E4", "#D3D9E2"],
    to: "@border-color",
  },
  {
    prop: ["color"],
    from: ["#666E79", "#5C6268"],
    to: "@text-color",
  }
];
Enter fullscreen mode Exit fullscreen mode

Transform

const syntax = require("postcss-less");
var fs = require("fs");
const path = require("path");
const rules = require("./rule.js");

var glob = require("glob");

function log(file, node, to) {
  console.log(
    "\x1b[32m",
    `convert ${file} ${node.source.start.line}:${node.source.start.column}  ${node.parent.selector} ${node.prop} from ${node.value} to ${to}`
  );
}

let codes = {};

// options is optional
glob("./src/**/*.less", function (er, files) {  
  files.forEach((file) => {
    var ast = syntax.parse(file);

    // traverse AST and modify it
    ast.walkDecls(function (node) {
        rules.forEach((item) => {
          if (item.prop.includes(node.prop) && item.from.includes(node.value)) {
              node.value = item.to;
              log(file, node, item.to);
          }
        });
    });
    fs.writeFileSync(path.resolve(file), syntax.nodeToString(ast));
  });
});
Enter fullscreen mode Exit fullscreen mode

Main steps

1、Read all the less files with glob

2、Use postcss-less Convert less to AST

3、Iterating over all CSS properties, the decision is replaced with the less variable in the rule

4、Convert to less write file

The above code is the simplest, and there are many styles that are not covered

For example: border You can write border-color and so on.

Use VSCODE regular query missing color

When the above rules cannot cover all the project code, the developer can enter the rules in VSCODE.

(#[a-fA-F0-9]{3})|(#[a-fA-F0-9]{6})|^rgb
Enter fullscreen mode Exit fullscreen mode

Find out the colors in the code and extract them into less variables one by one.

image

Summary

  1. This article summarizes some common ways of front-end multiple skins. Through the most comparison, I find that it is the most convenient to generate skin style through PostCSS in our project, and it is also the easiest to make your website support dark mode. I opensource postcss-multiple-themes to Github and released the npm package.

  2. Thinking about how to replace the css color in the old project with variables through PostCSS, when there are more projects, the labor cost is saved to a certain extent.

Last

If you are also doing change skin work for WebApp and are plagued by the problem of multi-skin in the front-end, I hope this article will be helpful to you. You can also exchange your ideas and experiences in the comments area. Welcome to explore the front-end.

Top comments (4)

Collapse
 
mauron85 profile image
Marián Hello

This is IMHO best solution to the problem. But I have problems with my builds. Sometimes the builded css is perfectly fine, but sometime is broken, meaning that css is missing classes. I think it's related to my setup only, where I'm using it on ant design project and not all components have separate index.js and dark-theme.less and default-theme.lss. Only the root component has. Anyway I wanted to say thank you for inspiration.

Collapse
 
maqi1520 profile image
Alex Ma

Antd dynamic packaging will be some troubles. You can use Antd v4, It have css variables.

Collapse
 
mauron85 profile image
Marián Hello

I want to preserve IE11 compatibility for some time. Anyway I found problem with the plugin. Will post PR.

Thread Thread
 
maqi1520 profile image
Alex Ma

Thank you very much.