It's not like I dislike Tailwind, but I can't say that I'm in love with it either. I'm keen on the "atomic CSS" part of it, but reading all this mass of short class names in the HTML is a bit of a hard job, don't you find? Especially when it comes from an AI model and the diff is really big. Also, I've noticed that models feel free to use anything Tailwind offers, and it's hard to harness them properly.
That was the reason I decided to look for another way to write CSS: one that gives me flexibility, sets clear architectural boundaries for how styles are organized and composed, and is easy to read and maintain. I chose CSS Modules because it can be harnessed and checked very well. The authoring remains ordinary CSS, plus the composes extension when a project composes on the CSS side, so every diff is plain CSS you can compare line by line. The great part: you can easily debug it. You know, browser DevTools are really good for that.
So, my claim upfront: once the project plumbing exists, a small set of conventions on top of CSS Modules gives you a compact workflow for common component styling: a shared style API, typed variants, observable state, deterministic local overrides, and semantic color theming. It also gives you readable diffs, useful names in DevTools, and compile-time errors instead of a silent undefined in the class string.
What I'm proposing here is a new CSS methodology. A young one, to be honest: this article is its first full write-up, a beginning rather than a finished spec. But it's aiming for the same shelf where BEM, OOCSS, SMACSS, and others sit, and every system on that shelf started the same way: as somebody's blog post or conference talk. Mine proposes an architecture too, down to how a single module file is laid out. I respect all of them, but they were invented before components, and most of their rules exist to solve one big problem: scoping styles through naming discipline.
CSS Modules already scopes styles mechanically, so all that discipline can be spent on better things: design tokens, cascade layers, compile-time types. The old methodologies taught us how to survive the global namespace. Mine gets to assume the problem is solved and build on top. Atomic CSS sits on that shelf too, but on a different branch: I'm not replacing it, I borrowed the atoms idea from it.
TLDR
It's real CSS all the way down, just better behaved:
- Atoms you snap together like Lego:
cx(layout.padM, typography.h1). - Cascade layers, so in this setup normal local styles beat shared ones without specificity arm-wrestling or
!important. - Generated types: a typo in a class name breaks the build, not the button.
data-*state you can flip right in the Elements panel, instead of clicking through the app to reproduce a bug.- Two-tier color tokens, so "wait, which gray is our gray?" has exactly one answer.
And you don't have to adopt it by hand: npx skills add a-dev/skills --skill css-modules-setup --skill css-modules installs , the best pick for now. In a nutshell, it fixes composes imports, removes duplicates (yes, Vite creates them easily), fixes HMR and some other bugs, and renders classes in a way that works well. I recommend reading the README of this library, it has some good explanations and examples.
The other thing is a bit opinionated, but I think I'm right in this opinion 😉. In CSS I use kebab-case (.icon-light-s); in a component I use camelCase (styles.iconLightS).
camelCaseOnly is what makes that split real: only the camelCase key exists at runtime.
Generated declarations expose the same shape to TypeScript. Keep in mind that Vite doesn't typecheck, so the error appears only when tsc --noEmit runs.
Here's a complete Vite example. Merge it into your existing config; don't replace the plugin array.
// vite.config.ts
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { patchCssModules } from "vite-css-modules";
export default defineConfig({
css: {
devSourcemap: true,
modules: {
localsConvention: "camelCaseOnly",
},
},
plugins: [
react(),
patchCssModules({
generateSourceTypes: true,
declarationMap: true,
}),
],
});
Now my-component.module.css.d.ts describes the exported keys, and its declaration map connects them back to the CSS source. A typo then fails under tsc --noEmit:
styles.iconLigthS;
// Property 'iconLigthS' does not exist.
I put generated declarations in .gitignore, so a fresh clone has none. The dev server regenerates them during development; CI and package lifecycle scripts must generate them before typechecking.
For npm, that order can look like this:
{
"scripts": {
"css:generate": "vite-css-modules",
"css:types": "tsc --noEmit",
"css:check": "npm run css:generate && npm run css:types"
}
}
The setup skill writes the equivalent commands for the project's package manager. runs.
I break the #styles alias or remove declaration generation.
AUDIT FAILS. The catch the TSX side. The .
I use a primitive palette token, a raw hex color, or a local theme selector inside a component module.
AUDIT OR CSS:CHECK FAILS, but only if the project adopted the color-token contract. The audit catches palette leakage; Stylelint handles the per-edit rules.
I compute styles[kind], use camelCase in CSS, write a bare descendant element selector, keep an ordinary visual inline style, or reach for !important.
CSS:CHECK FAILS. ESLint and Stylelint cover those source rules. Whether meta is a good role name is still REVIEW. A linter can't tell what the component means.
I put a module in the wrong cascade layer.
CSS:CHECK FAILS when the project profile gives that file one unambiguous owner. If ownership itself is unclear, the result is REVIEW, not a layer name invented by the checker.
I add a class to the shared module or break an external composes path.
CSS:CHECK FAILS. The follows the project's admission rule; it doesn't invent the answer.
I use 9px, invent size-xl, or ignore a spacing scale that doesn't exist.
PROJECT DECIDES. The generic harness stays silent. A project-specific check may fail if the project has one, but this methodology doesn't define or enforce that scale. The ; you can install them with npx skills add a-dev/skills --skill css-modules-setup --skill css-modules. Choose a project or global installation, select your host (codex or claude-code), then verify both skills in the host's catalog. The first one, css-modules-setup, is for setting up this whole system, including the vite-css-modules library, the Vite config, and the path alias. It's meant to be invoked explicitly, since setup happens once; how manual commands behave depends on the host.
The second one, css-modules, works during everyday coding after the project adopts the methodology (the model can invoke it on its own). The canonical versions live in the skills repository; the older copy beside this article is only kept for history. If you want to get to the bottom of this methodology, I recommend reading these skills: they're really well documented and have examples.
Think of these skills as a starting point. In recent months I've often found myself adapting different skills to specific projects, and I believe that's the right way to harness AI: tune them to your needs, architecture, and paths. A detailed harness for your project always beats a general one, with better results and fewer hallucinations.
Objections, or AI explores the methodology
At this point in the article, I asked AI to find objections that I hadn't noticed in the system and to give me the weaknesses without sugar-coating them. We argued for a while, and I made a list of the main conclusions.
"Generated .d.ts files are a codegen loop. Tailwind doesn't need one."
True: this is the system's one moving part, I don't like it, and this is the place where I didn't find an ideal solution and settled for a compromise. But in practice it costs one devDependency and one script: typings are gitignored, the dev server regenerates them on the fly, and CI runs css:generate before css:types. That's the whole tax; the payoff is compile-time class safety. And an agent handles this loop happily: it regenerates after changes, runs the checks, and always knows the current class names.
"The variant maps are boilerplate. cva solved this."
The map is boilerplate with a job: it's the component's style API, written down and checked for exhaustiveness. And nothing locks you in: cva happily accepts CSS Modules classes, so if your team loves cva, the map just moves inside it.
"Atomic CSS bundles are smaller."
At scale, yes, conceded. Gzip can shrink the repeated bytes, but it doesn't solve parse cost, CSSOM memory, or unused CSS. My claims in this article are about readability, debugging and harness. If payload is what matters in your application, measure it there. You can still use shared single-purpose classes with cx(l.padM, t.h1). The defaults just point in a different direction, and I don't promise identical output.
"Kebab-case in CSS, camelCase in TSX: you split grep in two."
Also true: searching variant-primary finds the CSS, variantPrimary finds the usage. Two greps. But try to find styles[`button--${variant}`], ah?
Declaration maps soften navigation in the editor, where a typed usage jumps straight to the CSS source. How readable the runtime class names are depends on the build mode and configuration.
"It's all discipline. Nothing forces your rules."
Half true. Some objective parts can be checked, but a rule is only enforced when a failing check exists. The rest is audited or reviewed. The complaint test above makes that boundary explicit.
"composes is not standard CSS."
Formally it's ICSS, an implementation detail of CSS Modules. Support is widespread, but external imports and aliases vary by build pipeline, so verify them in yours. The system stands without it: composing in markup with cx does the same job.
"Who decides what is shared and which layers exist?"
The project does. The modules and layer list in this article are defaults to start from; the methodology doesn't legislate them. The one stable requirement is that the chosen boundaries and override order get written down and used consistently.
"Does leaving spacing and sizing open weaken the design system?"
No, because that is intentionally a project design-system concern. A project can enforce its own scale or allow fluid and contextual values; this methodology doesn't choose for it.
"What about monorepos, multiple Vite apps, Storybook, SSR, tests, and theme hydration?"
The methodology alone doesn't solve this. Each environment still has to verify its own aliases, generated declarations, layer entry point, and theme bootstrap. The generic harness defines what correct means; the project's checks have to prove it.
And if you're happy with Tailwind, keep it; this article isn't trying to convert you. Steal the parts that work anywhere: the state-channel convention, cascade layers, and the two-tier design tokens fit any styling approach, even a utility-first one.
That's it for now. The methodology is young, and I expect it to evolve. I hope this article gives you a good overview of the system and its principles. Please share your opinions and objections: a methodology this young needs arguments more than praise. Questions are welcome too.
SOCIAL SHARE CARD GENERATOR