Atoms, layers, types, tokens: a new CSS methodology built on CSS Modules

작성자

카테고리:

← 피드로
DEV Community · a-dev · 2026-07-21 개발(SW)

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. Plus the composes extension, 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.

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. 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. This gets to assume the problem is solved and build on top. Atomic CSS/UnoCSS 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.
  • Generated types: a typo in a class name breaks the build.
  • data-* state you can flip right in the Elements panel.
  • Two-tier color tokens.

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 infrastructure defines 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.

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 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 the infrastructure is the @layer atoms wrapper. It’s the mechanism that makes all the overrides painless.

The rule is simple: for normal author declarations, unlayered styles beat layered ones, regardless of selector specificity. So the reference setup takes three steps. First, declare the layer order once in your global CSS:

/* src/shared/styles/index.css */
@layer reset, base, atoms, ui;

Enter fullscreen mode Exit fullscreen mode

Treat this as a starting map. A project can rename, split, or add layers and module boundaries, as long as there is a single recorded order and it’s clear who owns what.

Second, wrap shared styles in their layers: atoms in @layer atoms, UI primitives like a button in @layer ui. Third, 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. But with layers, the local rule wins deterministically: you don’t have to count specificity or reach for !important.

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 infrastructure 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);
  /* ... */
}

Enter fullscreen mode Exit fullscreen mode

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));
}

Enter fullscreen mode Exit fullscreen mode

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;
  }
}

Enter fullscreen mode Exit fullscreen mode

data-theme sets by application bootstrap code, an SSR-safe theme script, or the framework integration. Component modules never touch the theme themselves.

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,
    }),
  ],
});

Enter fullscreen mode Exit fullscreen mode

Now my-component.module.css.d.ts describes the exported keys, and its declaration map connects them back to the CSS source. And you don’t depend on an editor extension, you can use VS Code, Zed, Vim, etc.

styles.iconLigthS;
// Property 'iconLigthS' does not exist.

Enter fullscreen mode Exit fullscreen mode

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"
  }
}

Enter fullscreen mode Exit fullscreen mode

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 you’ll soon see why it’s convenient.

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'

Enter fullscreen mode Exit fullscreen mode

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%);
  }
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

I personally prefer some verbosity in naming, and that’s why I intentionally don’t use p4 or anything else common in Tailwind class names, but you can.

And as you may have noticed, I import the cx function, which is a simple helper for combining classes. You can use any library for this, like classix/clsx/classnames. I prefer to import it as cx and not care how it actually works inside. And I use it only for combining component variants, never for dynamic state; I’ll tell you why a bit further on.

So what if we need to add some specific design and want to use local styles? This is the moment when it all becomes really great, because you can use local styles and shared atom styles together with composes. (The composes keyword itself is widely supported)

/* src/.../my-component/my-component.module.css */
.root {
  composes: pad-m bg-top-gradient from "#styles/layout.module.css";
  grid-column: 1 / 3;
}

.title {
  composes: h1 from "#styles/typography.module.css";
  margin-top: 9px;
}

Enter fullscreen mode Exit fullscreen mode

Pay attention to the naming: in composes you write class names exactly as they are in the CSS source: kebab-case (pad-m), not camelCase (padM). This line is resolved by the CSS pipeline itself, without any JS imports, so the camelCase export convention doesn’t apply here.

And then in your component:

import styles from "./my-component.module.css";

function MyComponent() {
  return (
    <div className={styles.root}>
      <h1 className={styles.title}>Hello World</h1>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

An important detail: composes doesn’t create a CSS rule with additional declarations in it. No, it adds a class to the HTML element. In the rendered HTML it looks like this:

<div class="root pad-m bg-top-gradient">
  <h1 class="title h1">Hello World</h1>
</div>

Enter fullscreen mode Exit fullscreen mode

As you can see, you can recreate Tailwind-like classes, but in a more maintainable way. Honestly, I don’t think you should do it, because the philosophy of this approach is a bit different: stay close to real CSS, but also have atoms or shared styles that can be used in components and are easy to read and debug.

And if you need to override something, you can do it in your component’s local styles, and it looks like the real CSS way. The @layer boundary is what makes that local override reliable.

When a local style should graduate into that shared API is also a project decision. My conservative default: when a second component needs it for the same reason. Your project can pick a different rule.

That was the core of this methodology. The next question is component variants and state.

Class names and data attributes

This one is really opinionated, but it pays off: represent state through the channel that owns its meaning.

Input or state Representation Selector Closed design variant Typed CSS Module lookup .variantPrimary Native HTML state Native attribute or pseudo-class :disabled, [open] Meaningful accessibility state aria-* value [aria-pressed="true"] Private boolean state Presence-based data-* [data-loading] Headless-library state Existing library attribute Library-defined Continuous runtime value Private CSS custom property var(--_progress)

Use data-* only when the state has no native or accessibility representation. If the platform or a headless library already exposes it, style that source directly.

Here’s how it looks for a button:

// ui/button.tsx
import type { ComponentPropsWithoutRef } from "react";
import { cx } from "#styles";
import styles from "./button.module.css";

type Variant = "primary" | "secondary" | "outline";
type Size = "s" | "m" | "l";

type ButtonProps = ComponentPropsWithoutRef<"button"> & {
  variant: Variant;
  size: Size;
  loading?: boolean;
};

const VARIANT_CLASS = {
  primary: styles.variantPrimary,
  secondary: styles.variantSecondary,
  outline: styles.variantOutline,
} satisfies Record<Variant, string>;

const SIZE_CLASS = {
  s: styles.sizeS,
  m: styles.sizeM,
  l: styles.sizeL,
} satisfies Record<Size, string>;

export function Button({
  variant,
  size,
  loading = false,
  className,
  disabled,
  children,
  ...buttonProps
}: ButtonProps) {
  return (
    <button
      {...buttonProps}
      className={cx(styles.root, VARIANT_CLASS[variant], SIZE_CLASS[size], className)}
      disabled={disabled || loading}
      data-loading={loading || undefined}
      aria-busy={loading || undefined}
    >
      {children}
    </button>
  );
}

Enter fullscreen mode Exit fullscreen mode

One trap here: loading || undefined. If you write data-loading={false}, React keeps data-loading="false" in the DOM. [data-loading] still matches it. Passing undefined removes the attribute.

That presence rule is only for boolean data-*. ARIA uses values: aria-pressed="false" is meaningful and must stay in the DOM.

Also note the types: the map and the union can’t drift apart. Add "danger" to Variant, and TypeScript underlines VARIANT_CLASS until you add both the class and the entry.

Extending a component becomes a mechanical, compiler-guided task (try to get this from a class string).

And in the CSS module:

/* ui/button.module.css */
@layer ui {
  .root {
    border: none;
    /* ... */
  }

  .root[data-loading] {
    opacity: 0.5;
  }

  .root:disabled {
    cursor: not-allowed;
  }

  .variant-primary {
    background-color: var(--color-action-bg);
    color: var(--color-action-text);
  }

  .variant-secondary {
    background-color: var(--color-panel-bg);
    color: var(--color-text-primary);
  }

  .variant-outline {
    color: var(--color-action-bg);
    background: transparent;
  }

  .size-s {
    padding: 4px 8px;
    font-size: var(--fs-s);
  }

  .size-m {
    padding: 8px 12px;
    font-size: var(--fs-m);
  }

  .size-l {
    padding: 12px 16px;
    font-size: var(--fs-l);
  }
}

Enter fullscreen mode Exit fullscreen mode

A small perk that I really enjoy: state debugging now happens right in the Elements panel. Open DevTools, add data-loading to the button by hand, and you instantly see the loading style, without needing React DevTools or clicking through the app to reproduce the state. It works in the other direction too: when a bug report comes in, you can rebuild the broken state attribute by attribute.

Also, I insist on short names: instead of BEM or repeating the component name, use just root, icon, label, header, item, meta, etc. If you have a component with several variants, you can use variant-primary, variant-secondary; for sizes, size-s, size-m, size-l (yes, I prefer an alpha size scale: it’s flexible and easy to read). And if you have a component with several parts, you can use header, body, footer, etc.

And even a year after you last looked at a component, you can quickly tell what’s going on in it, because the names describe roles and the state is visible right in the markup.

Skills for setup and continuous work

Of course, these days you also need a good harness for the AI that will work with this system. I made two skills; you can install them with npx skills add a-dev/skills --skill css-modules-setup --skill css-modules, project installation is preferable. 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.

The second one, css-modules, works during everyday coding after the project adopts the methodology (the model can invoke it on its own). 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.

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. Questions are welcome too.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다