Atoms, layers, types, tokens: a new CSS methodology built on CSS Modules
Opening Thoughts
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.
TL;DR
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 two canonical skills that let an AI agent set the system up and then code in it with you. You read the article, your agent reads the manual.
What I wanted to keep from Tailwind
And to be fair from the start, there are two things Tailwind still does better: zero-setup prototyping (this system has a setup cost, paid once per project) and raw CSS bytes at scale. I can live with both trade-offs, because honestly, prototypes are now easier to make entirely with AI and no one cares what's inside; we only need them to test ideas. This methodology optimizes for maintenance and debugging rather than minimum CSS bytes. If stylesheet size or parse cost actually matters in your project, measure before you commit.
Also, Tailwind gives you names, so you don't have to invent them (naming is genuinely hard). I tried to alleviate this pain with a small set of conventions that both you and the AI can follow. Some people will say that they prefer Tailwind because everything exists in one file. I'm not that person, because that's exactly what turns PR diffs into soup, especially when it comes to a basic UI component like a button with a lot of variants. I like CSS, and I'm not afraid to say it.
Note that the attached implementation isn't universally applicable for now: it uses Vite and React. The principles can apply elsewhere, but another framework or a vanilla project needs its own setup and verification.
Architecture at a glance
The architecture is simple:
- Global plumbing establishes the selected layer order, color roles, and generated types.
- A project-defined shared API sits above it.
- Local component modules consume and override that API.
Global: color tokens + color-scheme + recorded layer topology + generated types โ Shared API: atoms module/modules, shared ui-components โ Local styles: unlayered feature modules in the reference setup.
Here atoms, ui, and #styles are just the names I picked. Nothing in the system depends on them. A project can use one shared module, role-based modules (layout/typography/spacing/etc.), or a different map entirely. The names aren't the point. The point is that the shared boundary is written down and its place in the cascade stays predictable.
Local styles then follow whatever override strategy the project chose. This methodology doesn't prescribe a spacing or sizing scale. Those values and tokens belong to the project's design system; the methodology only says how styles are organized, composed, checked, and overridden.
Cascade layers: floor and ceiling
In my reference setup, the first piece of plumbing is the @layer atoms wrapper. It's the mechanism that makes all the overrides painless. The rule from the CSS spec is simple: for normal author declarations, unlayered styles beat layered ones, regardless of selector specificity.
So the reference setup takes three steps:
- Declare the layer order once in your global CSS:
/* src/shared/styles/index.css */
@layer reset , base , atoms , ui ;
Treat this as a starting map. A project can rename, split, or add layers, and module boundaries don't have to match layer boundaries one-to-one, as long as there is a single recorded order and it's clear who owns what.
Wrap shared styles in their layers: atoms in
@layer atoms, UI primitives like our button in@layer ui.Don't wrap app components at all: they stay unlayered, so they always win.
That's the whole trick: shared styles are the floor, local styles are the ceiling.
Take a local .title that composes an h1 atom with margin: 0, then overrides it with margin-top: 9px. Both selectors are single classes with equal specificity, and without layers the winner would depend on the source order in the final bundle. Fragile and scary. With layers, the local rule wins deterministically: you don't have to count specificity or reach for !important, and import order stops mattering.
One nuance: layers give you determinism between layers, not inside one. If you combine two atoms that set the same property, the usual source-order rule still applies, and the later one wins silently. In practice, role-based naming makes such clashes rare.
Two-tier color tokens
The other part of the global plumbing was sorting out the mess with colors in the project's styles. After some trial and error, I came to the "severance" of color tokens into two tiers: palette and semantic color roles.
If you use Tailwind, you know this huge palette. It's good (you have a lot of options), but in a real project all those options erode the standard (good design is, above all, repeatability and consistency). Add a light/dark theme to it, and the remaining consistency falls apart in a moment.
In palette.css I keep vars for all the colors in lists of tones:
:root {
--color-gray-50 : oklch ( 96.6% 0.005 240deg );
--color-gray-100 : oklch ( 93% 0.008 240deg );
--color-gray-200 : oklch ( 87% 0.009 240deg );
--color-gray-900 : oklch ( 18% 0.015 240deg );
--color-blue-400 : oklch ( 70% 0.14 250deg );
--color-blue-500 : oklch ( 62% 0.17 250deg );
--color-blue-600 : oklch ( 54% 0.18 250deg );
/* ... */
}
And in colors.css I keep vars for the colors that are used in the project, mapped to the palette:
:root {
--color-text-primary : light-dark ( var ( --color-gray-900 ), var ( --color-gray-100 ));
--color-panel-bg : light-dark ( var ( --color-gray-50 ), var ( --color-gray-900 ));
--color-action-bg : light-dark ( var ( --color-blue-600 ), var ( --color-blue-500 ));
--color-action-text : light-dark ( var ( --color-gray-50 ), var ( --color-gray-50 ));
--color-accent-bg : light-dark ( var ( --color-blue-400 ), var ( --color-blue-600 ));
}
Components consume the semantic roles and never touch the raw palette directly. Other token families can exist too; this article only pins down color.
light-dark() reads the active color-scheme, so the global setup needs to connect theme selection to it:
/* Reference topology; use the project's selected layer map. */
@layer reset , base , atoms , ui ;
@import "./vars/colors.css" ; /* @import "./palette.css" exists only inside colors.css to keep the dependency chain correct */
@layer base {
html { color-scheme : light dark ; }
html [ data-theme = "light" ] { color-scheme : light ; }
html [ data-theme = "dark" ] { color-scheme : dark ; }
}
Who sets data-theme? Application bootstrap code, an SSR-safe theme script, or the framework integration. That code also owns persistence and the initial render. Component modules never touch the theme themselves.
The mapping doesn't replace runtime checks either: you still have to verify contrast, forced colors, and the first rendered theme in the environments you actually support.
TypeScript for CSS
First of all, I want to have type safety for my CSS Modules. I use Vite, and it has some problems with CSS Modules. I went through several libraries and even started to make my own, but ended up choosing vite-css-modules, 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. The reference fixture runs generation, tsc --noEmit, and a production build.
Core: make atoms in CSS Modules
Everything started from the idea of making atoms in CSS Modules. I wanted to have a set of classes that can be used in any component, but can also be easily overridden if needed. That isn't so easy, because importing them is always a problem, but I wanted to find a simple way.
For this, we should create a place where we put our "atoms" and global/common styles that we can use anywhere. I used the src/shared/styles/ folder and created an alias for it: I chose #styles. It's probably not the most popular choice (you'll more commonly see @/shared/styles), but it's shorter, and soon you'll see why it works well.
In this folder I created:
// src/shared/styles/index.ts
export { default as layout } from " ./layout.module.css " ;
export { default as typography } from " ./typography.module.css " ;
export { default as utils } from " ./utils.module.css " ;
export { cx , type ClassValue } from " ./lib/cx " ;
export { cssVars } from " ./lib/css-vars " ;
// ... and so on, whatever you need. Or only 'atoms.module.css'
Take this entry point as an example rather than a required list. An atom is simply a reusable class exposed through the project's shared style API; it can contain one declaration or several related ones.
Then, for example, I created classes like:
/* src/shared/styles/typography.module.css */
@layer atoms {
.h1 {
margin : 0 ;
font-size : var ( --fs-xxl );
font-weight : 600 ;
line-height : 1.27 ;
color : var ( --color-text-primary );
}
}
/* src/shared/styles/layout.module.css */
@layer atoms {
.pad-m {
padding-inline : 20px ;
padding-block : 16px ;
}
.bg-top-gradient {
background : linear-gradient ( 90deg , var ( --color-action-bg ) 0% , var ( --color-accent-bg ) 100% );
}
}
The values here are only examples. Use the spacing and sizing system your project already has, whether that means tokens, raw values, a grid, or something else.
Then you can use them in your components like this:
import { layout , typography , cx } from " #styles " ;
function MyComponent () {
return (
< div className = { cx ( layout . padM , layout . bgTopGradient ) } >
< h1 className = { typography . h1 } > Hello World </ h1 >
</ div >
);
}
Comments
No comments yet. Start the discussion.