Selectors for Humans, Hashes for Machines
One thing I like about CSS Modules is that it can replace every class name with a short hash. I can keep my class names as long and descriptive as I want, and the production build still ships four-character hashes. This split follows my rule for CSS: selectors should be written for human readability, but compressed for machine efficiency.
However, a short hash can collide. When two unrelated classes get the same hash, elements with either class receive both sets of styles, and nothing reports the collision until someone notices the bug on the page. Four characters allow about 16.7 million hashes, yet a project with 1,000 classes still has about a 3% chance that two of them share one. Neither Vite nor postcss-modules checks for collisions. The config below checks each hash as it is generated:
// vite.config.ts
import { hash } from "node:crypto";
import path from "node:path";
import { defineConfig } from "vite";
function createShortScopedName(length: number) {
const ownerByName = new Map<string, string>();
return (className: string, filename: string) => {
const owner = `.${className} in ${path.relative(import.meta.dirname, filename)}`;
const digest = hash("sha256", owner, "base64url").slice(0, length);
// A class name cannot start with a digit or with a hyphen and a digit, so any leading digit or hyphen gets a prefix.
const scopedName = /^[\d-]/.test(digest) ? `_${digest}` : digest;
const collision = ownerByName.get(scopedName);
if (collision && collision !== owner) {
throw new Error(`CSS Modules hash collision: ${collision} and ${owner} both became .${scopedName}`);
}
ownerByName.set(scopedName, owner);
return scopedName;
};
}
export default defineConfig(({ command }) => ({
css: {
modules: {
generateScopedName: command === "build" ? createShortScopedName(4) : "[local]-[hash:base64:4]"
}
}
}));Each hash is stored with the class and file it came from. When the same hash comes back for a different class, the build stops with an error that lists both classes, and increasing the length passed to createShortScopedName fixes the collision. Since the hash depends only on the class name and the file’s path relative to the config file, every class keeps the same hash from one build to the next and between client and SSR builds. The config needs Vite’s default PostCSS transformer, because Vite ignores css.modules under Lightning CSS. Lightning CSS cannot produce per-class hashes either: its [hash] covers only the file path, and a pattern without [local] gives every class in a file the same name.
I put the check in generateScopedName rather than in the getJSON callback. That callback also receives :export values, and two SCSS modules that both export 8px would stop the build although no two classes share a hash. Plugins such as vite-plugin-optimize-css-modules avoid collisions by numbering class names in the order Vite processes them, but that order is not stable. Six builds of the same 40 SCSS modules in Vite 8.3.4 produced six different stylesheets, and a separate SSR build named the classes differently from the client build. Processing order is also why a collision stops the build rather than renaming one of the two classes. The first class already has its hash when the second arrives, and which class comes first changes from build to build.
This site builds its CSS Modules this way: readable names in development, short checked hashes in production. To use this setup in another Vite project, give an agent this prompt:
Apply the CSS Modules config from
https://peiwen.lu/posts/hashing-collision-detection
to this project. In production builds, generateScopedName
returns a short SHA-256 hash of the class name and
relative file path, and throws when two classes get the
same hash. In development, names stay
"[local]-[hash:base64:4]".
- If a framework wraps Vite, use its Vite options. If CSS
goes through Lightning CSS, stop and report.
- Keep the project's hash length if it already sets one,
and replace any collision check in getJSON.
- Find code and tests that hard-code generated class
names.
- Verify: two builds give identical CSS; a temporary hash
length of 1 fails the build; with SSR, server and client
names match.
Report the changed files and the result of each check.