Nagi CSS
CSS, after the wind.
A structural contract for component CSS that derives class identities and owned selector paths from source, then checks that they stay aligned. It lets class names follow from the source, rather than gather around it.
Nagi (“NAH-ghee”) is Japanese for the calm after the wind. In a component, that calm begins when naming, structure, and ownership stop drifting apart.
Let naming settle
Nagi CSS begins with a bounded canonical form: given the same component source and project configuration, authors and agents derive the same root, element identities, and owned selector paths. The remaining anatomy and variant choices stay inside an explicit, reviewable vocabulary.
-
Derived, not remembered
A file name, an element, or a small structural table supplies the identity. The linter checks the result, so consistency does not rest on shared memory.
-
Structure remains visible
Owned selector paths follow the component tree. When markup moves, the corresponding CSS path moves with it—or the contract speaks.
-
Ownership has an edge
A component may place another owned surface, but it does not reach through that surface and style the child’s private DOM.
One component, named by rule
Little below needs to be invented. Each base identity follows from a lookup or a bounded structural choice, and the linter keeps that derivation attached to the markup.
<article class="invoice-card">
<header class="header">
<div class="media">
<img class="image" src="acme.svg" alt="Acme Ltd.">
</div>
<h3 class="title">Invoice</h3>
</header>
<div class="unit">
<dl class="list -description">
<div class="field -user">
<dt class="term">User</dt>
<dd class="definition">A. Lovelace</dd>
</div>
<div class="field -amount">
<dt class="term">Amount</dt>
<dd class="definition">
<span class="value" data-status="paid">¥12,000</span>
</dd>
</div>
</dl>
<div class="seg">
<small class="note">Paid July 1, 2026.</small>
</div>
</div>
<footer class="footer">
<button class="button -secondary">Download</button>
<button class="button -primary" disabled>
Send
</button>
</footer>
</article>
-
Surface — from the file name
invoice-cardis the component file's name, kebab-cased. Never chosen. -
Elements — from a fixed table
Common HTML elements map to one fixed identity: headings become
title,imgbecomesimage,dtbecomesterm, andddbecomesdefinition. These names are looked up, not invented. -
UI anatomy — a tiny allowlist
Names for common UI parts that HTML has no tag for:
field(form field or composite control),value,actions,media,iconby default — five words go a long way, and the list is configurable per project. -
Structural Tier Names (STN)
A structural ladder (
unit·seg·fr·g) for thedivs that nothing else fits — the tier is determined by depth, sowrapperandinnernever appear. -
Variants — modifiers, not names
-user,-primary, … — they qualify an anchor, never name what an element is, and stay alphabetical. -
State — attributes, not classes
disabled,aria-*,data-*— runtime state never becomes a class.
A checked example, kept executable
Nagi CSS is for teams that keep CSS beside component markup and want names, DOM paths, and ownership boundaries to remain reviewable as that markup changes. It is an ESLint contract for Vue, Nuxt, Svelte, and Astro—not a UI library, runtime, or required dependency on Nagi UI.
This checked Vue fixture derives its root from
profile-card.vue, uses fixed element identities,
and stops at the owned UserAvatar boundary.
<article class="app-profile-card">
<header class="header">
<h2 class="title">Profile</h2>
</header>
<div class="unit">
<span class="text">Ada Lovelace</span>
</div>
<user-avatar />
</article>
.app-profile-card {
> .header { > .title { margin: 0; } }
> .unit { > .text { color: var(--color-text); } }
> .app-user-avatar { align-self: center; }
}
Result: 0 errors.
-
The base identity is guessed
Violation
<article class="app-user-card">Diagnostic
nagi-css/surface-root-name Surface root must be named ".app-profile-card" from the configured prefix and Vue file name.Repair: use
app-profile-card, derived fromprofile-card.vueand the configuredapp-prefix. -
Markup moves but its old CSS path remains
Violation
> .header { > .title { > .text { color: var(--color-text); } } }Diagnostic
nagi-css/selector-mirrors-template Selector "> .text" does not follow the template: no element matches this path.Repair: follow the new owned path through
> .header > .unit > .text. -
A parent reaches into an owned child
Violation
.app-profile-card { > .app-user-avatar { > .image { border-radius: 50%; } } }Diagnostic
nagi-css/owned-surface-reach-in Selector "> .image" reaches below ".app-user-avatar", the root of an owned child component; that DOM belongs to the child's surface, so style it there or pass a value in.Repair: keep only external placement on
> .app-user-avatar; style the image inuser-avatar.vueor expose an intentional input.
Each diagnostic is asserted by an executable fixture using the real
ESLint plugin.
Read all four fixtures
or run vp run test and look for
ESLint validates the ProfileCard example and reports three isolated structural violations.
Bring the contract into a component
Install the ESLint plugin, then add it after the framework's official flat config. The framework keeps ownership of its parser and generated settings.
npm install --save-dev @nagi-labs/eslint-plugin-nagi-css
Using pnpm or Vite+? Use the equivalent development-dependency command for your package manager.
import nagiCss from "@nagi-labs/eslint-plugin-nagi-css"
export default [
// framework config first
...nagiCss.configs.recommended({
surfaceRootPrefixes: ["app-"],
}),
]
One ESLint run validates templates, selectors, CSS nesting, ownership, and values. A separate Nagi configuration file is not required for normal Vue, Nuxt, Svelte, or Astro setup.
Choose a guide for Vue, Nuxt, Svelte, or Astro.
For a complete isolated Vite project pinned to Node 22.18+, ESLint 10.10,
Vue 3.5.42, and published npm dependencies, use the
Vue minimal example.
Run vp install --frozen-lockfile,
vp run lint, and vp run build.
Tested beyond a toy component
To see how the contract behaves beyond a small fixture, we measured Nagi UI, a larger set of Vue components built with Nagi CSS. Its executable evaluation counts every literal class token, then separates names that are determined or bounded from the local words an author still chooses:
-
76.8%
contract-determined explicit bases
Of 555 explicit base identities, 426 follow directly from the surface, component boundary or slot, ARIA role, STN position, or native element mapping.
-
100%
explicit bases need no new vocabulary
The other 129 of 555 explicit bases (23.2%) select from four bounded anatomy words. They require a choice and are not counted as contract-determined; together the two categories cover 555 / 555 bases.
-
82.4%
determined with implicit components
Including 176 component tags whose identities are implicit, 602 / 731 identities are contract-determined. All 731 / 731 require no new vocabulary after bounded anatomy is included.
-
17.2%
author-named variants
Of all 670 explicit class tokens, 115 are variant occurrences whose local word is chosen by the author. Nagi CSS checks their form, ordering, placement, and peer relationship; it does not derive the word.
The 100% base result and the 17.2% variant result use different populations:
555 base identities versus all 670 explicit class tokens. An occurrence is
a use in markup, not a separate invention. The source set includes
documentation and preview fixtures, where the variant rate is higher, and
excludes package Blueprints. This is one self-hosted repository, not a
benchmark or a universal Nagi CSS rate. Read the
method and limitations,
inspect the
JSON result,
or reproduce it with vp run evaluate:naming.
The command is checked in Nagi UI's CI. These counts locate the remaining
naming choices; they do not measure human cognitive load.
A narrow guarantee, with visible tradeoffs
Tailwind puts presentational utilities in markup; StyleX provides typed,
deterministic property composition; Panda generates typed atomic styles
and recipes; CSS Modules provide local class scope. Nagi CSS does not
replace those guarantees. It keeps ordinary component CSS and instead
checks that semantic identities, selectors, and owned DOM paths agree.
Its experimental Tailwind @apply backend is
separate from the stable plain-CSS mode.
It does not prove that the DOM semantics, accessibility, visual result, or maintenance cost are correct. It cannot completely analyze dynamic class names or DOM outside the owned boundary. The question is narrower: when component-owned CSS is your chosen medium, does making these structural decisions executable help your real changes?
Follow a real application from Deep Sea's dashboard source into its lint configuration. Deep Sea also uses Nagi UI, but Nagi CSS itself does not require it.
Built for AI agents
The repository ships an agent skill that packages the contract as a workflow: what to derive, in what order, and how to verify the result. An agent generates against a deterministic contract; the linter checks the result. That loop keeps AI-written CSS inside the same rules applied to human-written code.
Thank you, RSCSS
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.