I love vanilla CSS and want to keep using it as the language evolves. These days, AI agents write most of it for me. As my work shifted toward reviewing their output, I felt it was time to rethink CSS conventions.
I want fewer naming decisions to make and review. Wherever a rule can settle a class name, the same HTML and component structure should lead to the same answer, whoever writes it. The choices that remain should be explicit, and a linter should check the result. I am happy with detailed rules if they mean less guesswork for both the agent and me.
HTML already carries meaning through its native elements and ARIA roles. Choosing appropriate elements and using ARIA where needed helps make the interface accessible and gives an agent reading the HTML clear information about each element's role and state. I wanted CSS naming to draw on that existing meaning, so there would be fewer names to invent and review.
The markup already has a structure, too. Frameworks can keep markup and scoped styles together in a component, and native CSS nesting lets nested CSS blocks visually reflect its parent-child relationships without a preprocessor. That suggested a way to make both naming and structure easier to review while continuing to use plain CSS.
I built Nagi CSS to put that idea into practice: ordinary component CSS, with naming and structural rules that ESLint can check. The plugin supports Vue, Nuxt, Svelte, and Astro.
Start with the HTML
Consider the formatting controls you might put in a text editor: a small component that toggles Bold and Italic and reports which formats are active. The source filename, excluding its framework-specific extension, is format-controls; the project uses the class prefix app-.
First, look just at the HTML the component renders with Bold active:
<section class="app-format-controls">
<h2 class="title">Text formatting</h2>
<div class="unit">
<span class="text">Apply to the current selection</span>
<div class="group" role="group" aria-label="Text formatting">
<button class="button -bold" type="button" aria-pressed="true">
Bold
</button>
<button class="button -italic" type="button" aria-pressed="false">
Italic
</button>
</div>
<span class="status" role="status" aria-atomic="true">
Active formatting: Bold.
</span>
</div>
</section>
Change the Bold button's button class to control, and ESLint reports that Nagi requires the class button for a <button> element. The tag supplies the name, and the linter checks it. Here is how the names in the rest of the example are determined or chosen.
Where the names come from
The base class identifies the element or part. In class="button -bold", it is button; -bold is a variant that distinguishes the Bold control.
| Class | Naming rule |
|---|---|
app-format-controls |
The component source filename, without its framework-specific extension, plus the configured app- prefix |
title |
The fixed mapping for heading elements, h1 through h6
|
button |
The native element's tag name |
group, status
|
The identifying role on each div or span
|
text |
A name chosen from the allowed vocabulary for common UI parts |
unit |
A div used only for layout, with no suitable role or UI part name; assigned from a predefined vocabulary explained below |
-bold, -italic
|
Static variants chosen by the author to distinguish the two button roles |
The base classes in the first four rows follow directly from the file, elements, roles, and project configuration. These mappings give CSS a consistent class vocabulary: a heading keeps title whether its tag is h2 or h3, while a role-based status message uses status.
For a div or span without an identifying role, two fallbacks remain.
UI anatomy supplies a small vocabulary for common parts that HTML has no specific element for. Here, the short instruction uses text. The default vocabulary is field, value, actions, media, icon, and text, and projects can configure it. Choosing the appropriate part still requires judgment, but does not require inventing a word.
Structural Tier Names (STN) cover divs that only arrange other elements and have no suitable role or UI part name. Instead of inventing names such as wrapper or inner, nested structural wrappers move from larger to finer parts through a fixed sequence: unit → seg (segment) → fr (fragment) → g (grain). This example has only one such div, so it uses unit.
Variants let the author name stable distinctions such as Bold versus Italic. Anatomy involves choosing from an existing vocabulary; variants allow new words. Here, -bold and -italic identify each button's function, while its on/off state lives in aria-pressed.
The order matters: choose the HTML for its meaning, use its element or identifying role where available, then fall back to anatomy and structural tiers. The existing role="status" determines status; it was not added just to obtain that class name.
Write CSS against that structure
Now use those names in the same component's stylesheet. Inside the component root, > follows the actual parent-child relationships: the buttons are inside .group, which is inside .unit.
You only need selector paths for the elements you style; unrelated DOM branches need no CSS blocks. The relevant styles are ordinary nested CSS:
.app-format-controls {
> .title { margin: 0; }
> .unit {
display: grid;
gap: var(--space-4);
> .text { color: var(--color-text-muted); }
> .group {
display: flex;
gap: var(--space-3);
> .button {
&.-bold { font-weight: 700; }
&.-italic { font-style: italic; }
&[aria-pressed="true"] {
background: var(--color-accent);
color: var(--color-accent-text);
}
}
}
> .status { color: var(--color-text-muted); }
}
}
If that looks like a lot of CSS to type, that is the part I usually delegate to an AI agent. I still have to read and maintain the result. Following the same naming and nesting rules gives me a consistent structure to review, while the linter checks the names and paths against the markup.
The buttons keep the same button -bold and button -italic classes as their state changes. The component updates aria-pressed, and CSS reads that attribute directly. The same state is exposed to assistive technology and used to style the active format.
The custom properties supply the project's design values. Nagi checks the contract; you choose the colors, spacing, and appearance.
Let the linter check the connection
Once names and selector paths follow rules, the linter can compare them with the source.
For example, rename the component root to .app-toolbar in both the markup and stylesheet. The styles can still work in the browser, but the lint rule named nagi-css/surface-root-name reports that a component source named format-controls requires .app-format-controls. The expected name no longer depends on what an author happens to choose.
It also checks relationships between otherwise valid names. This selector skips the group around the buttons:
.app-format-controls {
> .unit {
> .button { color: var(--color-accent); }
}
}
Both unit and button exist in the template, but .unit > .button does not. Another lint rule, nagi-css/selector-mirrors-template, reports the mismatch. The correct path is .unit > .group > .button.
This matters during edits: when markup moves, a selector that still describes the old structure becomes a lint error. An agent and a human reviewer can check that relationship with the same command.
The repository includes passing and failing fixtures, exercised by ESLint tests, so these checks can be run against actual component source.
Keep each component's internals inside its boundary
That structure check stops at another component's root. To see why, place the format-controls component inside an editor-panel component:
<main class="app-editor-panel">
<format-controls></format-controls>
</main>
The component tag is framework-neutral shorthand here. At that position, the child renders the .app-format-controls root shown earlier.
.app-editor-panel {
display: grid;
> .app-format-controls { inline-size: min(100%, 44rem); }
}
The parent controls the child's placement and available width. The child controls its internal layout and appearance. Because the child already has its file-derived app-format-controls root, the parent can select that root without passing another base class.
Reaching inside the child breaks that division:
.app-editor-panel {
> .app-format-controls {
/* Invalid: .unit belongs to format-controls */
> .unit { padding: 0; }
}
}
The nagi-css/owned-surface-reach-in rule reports the attempt to style the child's private DOM. To change its internals, edit the child's CSS or use an input it explicitly exposes, such as a prop or CSS custom property.
Owning both source files does not give the parent a spare key to the child's DOM.
This is also why I agree with Tailwind's recommendation to reuse complex UI through components. In Nagi, the markup and its styles travel together, while the parent depends only on the child's root and exposed inputs.
How much naming remains?
This small example needed no new words for its base classes. To see how much vocabulary a broader set of UI components would need, I examined Nagi UI, an experimental library of common UI components I built under the Nagi CSS contract. I chose Vue because its templates keep the HTML directly visible.
Using Nagi CSS 0.4.0, I measured 31 component source files in Nagi UI's documentation site. All 555 explicit base-class occurrences required no new vocabulary. Their names either followed directly from the contract or came from its bounded anatomy vocabulary:
| How the base class was named | Occurrences | Share of base classes |
|---|---|---|
| Derived directly from the contract | 426 | 76.8% |
| Selected from the anatomy vocabulary | 129 | 23.2% |
| Total requiring no new vocabulary | 555 | 100.0% |
The anatomy choices used just four words: actions, icon, text, and value.
Alongside the 555 base-class occurrences were 115 author-named variant occurrences, making up 17.2% of the 670 class occurrences in total. Like -bold and -italic in the opening example, these name stable distinctions. Nagi checks their form, ordering, and placement, but the author chooses the words.
The methodology and source data are public.
Let the agent write; review the decisions
In this workflow, an AI agent writes most of the markup and CSS, following the repository's agent skill. When moving an element changes its selector path, the agent updates that path too, then runs the linter to check conformance. The structural coupling remains, but maintaining those paths becomes part of the agent's work.
The human review can then concentrate on the decisions a rule cannot settle: whether the HTML semantics are correct, whether the component boundary makes sense, whether an anatomy or variant word expresses the intended role, and whether the result is accessible and visually correct.
Run the example, then change it
To make the example executable, the repository includes a concrete Vue implementation in examples/vue-minimal, with state, click handlers, a parent component, and design-token values.
Open it in StackBlitz, or run it locally with Node.js 22.18+ and the example's pinned pnpm version, 11.1.3:
git clone --depth 1 https://github.com/nagi-labs/nagi-css.git
cd nagi-css/examples/vue-minimal
pnpm install --frozen-lockfile
pnpm lint
pnpm dev
This is an independent project that installs the published plugin. You do not need to build Nagi CSS itself.
Toggle the buttons and watch their appearance and status message change. Then try one intentional mistake. In src/format-controls.vue, find the Bold button and change button to control in its class attribute, keeping -bold:
- class="button -bold"
+ class="control -bold"
Run pnpm lint. The <button> tag determines the class button, so Nagi reports the mismatch. Change control back to button to restore the passing example.
Use the same checks in your project
The package is the same across the supported integrations. Install the plugin first:
pnpm add -D @nagi-labs/eslint-plugin-nagi-css
Keep your framework's ESLint configuration and append Nagi CSS. The minimal configuration below uses Vue because it matches the runnable example:
import pluginVue from "eslint-plugin-vue"
import nagiCss from "@nagi-labs/eslint-plugin-nagi-css"
export default [
...pluginVue.configs["flat/essential"],
...nagiCss.configs.recommended({
surfaceRootPrefixes: ["app-"],
}),
]
The prefix is the same app- used throughout the article. Preserve any additional TypeScript and framework-specific settings in your project. There are setup guides for Vue, Nuxt, Svelte, and Astro.
To give your coding agent the Nagi CSS workflow, install the skill from your application repository's root with the skills CLI:
npx skills add nagi-labs/nagi-css --skill nagi-css
The installer detects supported coding agents and installs the skill for the current project. Restart the agent session after installation so it can discover the new instructions. If your agent does not support Agent Skills, use Nagi CSS's AGENTS.md as the compact fallback.
For the complete rules and their scope, see the full contract.
I would like to hear where this helps in your own components, and where it gets in the way. In particular: which naming choices would it remove for you, and which real refactor would make the structural rules too costly?
Acknowledgements
Nagi CSS would not exist in its current form without RSCSS by Rico Sta. Cruz.
RSCSS showed me that a small set of conventions—thinking in components, naming elements locally, and using direct-child selectors to protect component boundaries—could make CSS dramatically easier to reason about.
Nagi CSS takes those ideas in a more mechanically enforceable direction, deriving names from HTML and checking ownership boundaries statically. But its starting point is unmistakably RSCSS.
Thank you, Rico, for publishing an approach that has shaped how I think about CSS for years.
Top comments (0)