Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Controlling CSS module class names in Vite

INTRO

A couple of months ago, I've migrated my vim.morzel.net hobby project from Create React App to Vite and noted down a few things that required attention. Recently, while adding dark mode to the same project, I've spotted another difference between CRA and Vite: class names for CSS modules were build differently.

For example, this is what I had in CRA for file named DrillChoiceScreen.module.css and a class named Content:

Class names with CRA... Click to enlarge...

 

and this is what I got for the same thing in Vite:

Class names with Vite... Click to enlarge...

 

Notice that the file name prefix is missing. I preferred having it as it makes it obvious which .module.css contains the styling.

I've spent some time experimenting with Vite config for CSS modules, and logged few observations in case I need to tweak it again in the future, or you need to do it now :)

 

CONFIG FILE

As usual with Vite, the configuration happens in vite.config.ts, which is automatically added when you start a new project with npm create vite@latest command. This is how the fresh file looks like (at least for React/TypeScript+SWC in Vite 5.2.0, with semicolons added - cause I like 'em):

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react-swc";

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
});

And this is how it looks after adding a css.modules configuration to make the file name appear in generated CSS module class names:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  css: {
    modules: {
      generateScopedName: '[name]_[local]_[hash:base64:5]'
    }
  }
});

 

STRING PATTERN

Providing string pattern to generateScopedName property (as shown above) is the easiest way to affect generated class names. Vite uses postcss-modules (by default, but there's experimental support for Lightning CSS, configured differently). The interpolated string supports multiple placeholders which are described here.

While using the string was easy, the [name] pattern produces full name of the file. I name my CSS modules in this way: for component Example.tsx the styling goes to Example.module.css. Having "module" in class name was a bit annoying...

Fortunately, the generateScopedName can also take a function that gives a lot of control over the generated names.


CUSTOM FUNCTION

Below you can see an example of generateScopedName function config that creates class names containing: custom prefix _m, file name, class name and a line number.

This is based on example from postcss-modules docs but tweaked slightly to give more descriptive parameter names and to handle both Unix (LF) and Windows (CRLF) line endings.

For Example.module.css file and a MyClass class from line 10, it produces: m_Example_MyClass_10 as generated class name. 

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  css: {
    modules: {
      generateScopedName: (className, filePath, css) => {
        const classNameIndex = css.indexOf(`.${className}`);
        const lineNumber = css.substr(0, classNameIndex).split(/\r?\n/).length;
        const fileName = path.basename(filePath, '.module.css');
        const prefix = 'm_';

        return `${prefix}${fileName}_${className}_${lineNumber}`;
      },
    }
  }
});

When deciding on a class name, it's important to remember that it should not start with a digit. Best to always include some prefix (can be as simple as an underscore). Watch out especially if you would like to have the name started with content hash (nothing worse than a flaky issues caused by some hashes starting with a digit).

Speaking of prefixes and hashes: the css.modules has a hashPrefix property, but mind that it doesn't control what's prepended to the class name. It affects the computed hash value which you can include in the string version of generateScopedName with the [hash] pattern. 

This is how a function that includes hash of CSS (full module file content) could look like (provided that import crypto from 'crypto'; is added):

generateScopedName: (className, filePath, css) => {
  const classNameIndex = css.indexOf(`.${className}`);
  const lineNumber = css.substr(0, classNameIndex).split(/\r?\n/).length;
  const fileName = path.basename(filePath, '.module.css');
  const prefix = 'm_';

  const hash = crypto.createHash('sha1').update(css).digest('hex').substring(0, 4);

  return `${prefix}${fileName}_${className}_${lineNumber}_${hash}`;
}

The crypto module contains tones of options, but for the purpose of content hash suffix simply taking a few characters from SHA-1 hex representation should be ok.

 

DEV AND RELEASE NAMES

As show earlier in this post, the defineConfig function can take an object that describes the configuration, but it can also take a function. Vite docs call it conditional config. The function receives and object with a couple of properties, one of them is command. Using this property you can distinguish between running the app locally while developing (npm run dev) and building for release (npm run build).

Here's an example of conditional config that uses hashes for class names for build and much longer and more descriptive names for development:

export default defineConfig(({ command }) => {
  return {
    plugins: [
      react(),
    ],
    css: {
      modules: {
        generateScopedName: (className, file, css) => {
          const classNameIndex = css.indexOf(`.${className}`);
          const lineNumber = css.substr(0, classNameIndex).split(/\r?\n/).length;
          const fileName = path.basename(file, '.module.css');
          const prefix = 'm_';

          let result;

          if (command === 'build') {
            const hash = crypto
              .createHash('sha1')
              .update(className + lineNumber + css)
              .digest('hex')
              .substring(0, 8);

            result = `${prefix}${hash}`;
          } else {
            const hash = crypto.createHash('sha1').update(css).digest('hex').substring(0, 4);
            const combined = `${prefix}${fileName}_${className}_${lineNumber}_${hash}`;

            result = combined.replace(/(?<=^.+_)Top_\d+_/, '');
          }

          return result;
        }
      }
    }
  };
});

Notice that the hashed value for command === 'build' combines the class name, line number and CSS. In the else branch (development) just the CSS is hashed, but there the hash is only an addon on much more informative name.

In the development mode you can also notice a replace call with a regular expression. It may look a bit complicated because it uses lookbehind assertion, but it's there to shorten names such as Example_Top_1_Something_5 to Example_Something_5 (I'm using Top as the "main" class name for a component that uses CSS module).