@arag 0.1.0

Cascade layers

Six layers, declared once, in a fixed order. They are what replaces specificity hacks, deep selectors and priority-raising declarations — and they are why your own CSS overrides this package without doing anything special.

The order

One line, at the top of src/index.css, before anything is imported:

@layer reset, tokens, base, components, utilities, overrides;

A layer statement like that one does two things. It creates all six layers, and it fixes their relative order permanently — later mentions of a layer name do not move it. Every @layer block in every source file that follows is therefore slotting content into a position that was already decided here.

The placement is legal, not lucky. CSS allows exactly two kinds of rule to appear before @import: @charset and @layer statements. So the ordering declaration can sit above the imports, which is the only place it can sit if it is going to win — a layer's order is set by where its name is first seen, and if the imports came first, the order would be whatever order the files happened to load in.

The real file, in full:

@layer reset, tokens, base, components, utilities, overrides;

@import url("./reset.css");
@import url("./tokens.css");
@import url("./base.css");

/* Components and utilities land next session.
@import url("./components/button.css");
@import url("./utilities.css");
*/

Note what is not there: no declarations. index.css orders and imports, and that is all it does. That is what makes the individual files linkable on their own — each one declares its own @layer block internally, so linking tokens.css alone still produces layered, correctly-ordered CSS.

What each layer is for

Lowest priority at the top. Anything unlayered beats all six.
Layer Belongs here Never here
reset Removing browser default rendering: box sizing, margin zeroing, control normalisation, [hidden], the reduced-motion block. Any var(). Any colour, font family, font size or spacing that is not a zeroing-out. This file is token-free so it stays correct standalone.
tokens Custom property definitions on :root, the ramp generators, color-scheme, and the [data-theme] switches. Anything that paints. No element selectors, no properties other than custom properties and color-scheme.
base Token-aware defaults for bare elements — body, headings, links, lists, tables, form controls, focus, selection, details, dialog. Classes. Literal values. Anything that assumes a component wrapper exists.
components Named, self-contained pieces — one file per component, class-based, composing tokens. Element selectors that reach outside the component. Literal values. Utilities in disguise.
utilities A static, hand-picked class list. Single-purpose, named, finite. Generated classes, arbitrary-value syntax, anything a scanner would produce. If it cannot be named, it does not go in.
overrides Nothing, in this package. Declared and left empty on purpose, reserved as the consuming application's escape hatch — the one place app CSS can go that still ranks above every @arag layer. Anything shipped by @arag. If the package ever writes into this layer it has stopped being an escape hatch.

Unlayered code wins

This is the rule that makes the rest of the page work, and it is the one people get backwards. In the cascade, unlayered styles have higher priority than any layered style. Not lower. A declaration that is not inside any @layer block beats a declaration that is, before specificity is even looked at.

Your application CSS is unlayered unless you deliberately put it in a layer. So it sits above all six @arag layers automatically, on day one, with no configuration.

Worked example. The package, inside its base layer:

/* @arag/css/src/base.css */
@layer base {
  button {
    background-color: var(--color-surface);
  }
}

Your app, in a stylesheet loaded afterwards, with no layer around it:

/* app.css */
button {
  background-color: rebeccapurple;
}

Both selectors are button. Both have specificity (0,0,1). They are exactly as specific as each other, and yours wins — not by a nose, and not because it came later in source order, but because layer origin is compared first and unlayered outranks layered outright. Reorder the two stylesheets and the answer does not change.

The payoff

This is the entire reason no priority-raising declaration exists anywhere in the package. A design system only needs !important when it is trying to win a fight it might otherwise lose. Layers mean @arag is never in that fight: it has deliberately placed itself underneath you, so it can hold opinions without imposing them.

Layer order beats specificity

The corollary, and the part that costs people an afternoon. When two declarations for the same property sit in different layers, the layer order decides which one applies, and specificity is never consulted at all. It is not a tiebreak. It does not get a vote. A one-character type selector in a later layer beats an ID selector in an earlier one.

Here is the case that actually bit this codebase. reset.css, in the lowest layer, hides anything carrying the hidden attribute — an attribute selector, specificity (0,1,0):

/* src/reset.css */
@layer reset {
  [hidden] {
    display: none;
  }
}

Later, base.css made labels block-level so they stack above their control — a bare type selector, specificity (0,0,1), which is lower:

/* src/base.css — the bug */
@layer base {
  label {
    display: inline-block;
  }
}

Read those with specificity in mind and [hidden] obviously wins. It does not. base is a later layer than reset, so base wins the property outright, and <label hidden> rendered on the page. Every form that toggled a label with the hidden attribute showed it anyway.

The fix in base.css is to decline the fight rather than try to win it:

/* src/base.css — the fix */
@layer base {
  label:not([hidden]) {
    display: inline-block;
  }
}

:not([hidden]) makes the base rule simply not match a hidden label, so the reset rule is the only display declaration in play and the element stays hidden. Note that this is a scoping change, not a priority change — the selector got narrower, not louder.

Rule to follow

Any display declaration written in a layer above reset needs the same :not([hidden]) guard. That currently means base, components and utilities — the last two are empty today, which is exactly why this is written down now rather than rediscovered later. It applies to unlayered app CSS too: a plain .card { display: grid } in your own stylesheet will show a <div class="card" hidden>. The guard is cheap; the bug is invisible until a QA pass in Mongolian finds a form with two labels in it.

Overriding from your app

Four recipes, roughly in the order you should reach for them. The first two cover almost everything.

Restyle an element

Write the plain rule. No layer, no wrapper, no extra specificity. It wins because it is unlayered.

/* app.css */
a {
  text-decoration-thickness: var(--border-width-2);
}

table caption {
  text-align: start;
}

Change a token globally

Usually better than restyling rules, because one token change propagates to everything downstream of it — including components you have not written yet. Redefine the custom property on :root in your own CSS. Since tokens.css defines it inside @layer tokens and yours is unlayered, yours wins at the same specificity.

/* app.css */
:root {
  --radius-md: var(--radius-none);
  --color-brand: var(--color-brand-700);
}

Redefine the semantic role rather than the ramp step where you can. Changing --color-brand moves everything that reads it; changing --color-brand-600 moves the ramp itself, and anything else keyed to that step moves with it.

Change tokens for one subtree

Custom properties inherit, so scoping an override to a selector re-tokenises everything inside it and nothing outside it. This is how you give the kiosk app bigger touch targets without forking the package.

/* app.css */
.kiosk {
  --space-4: var(--space-6);
  --text-base: var(--text-lg);
  --size-control: var(--size-touch);
}

This composes with [data-theme] rather than competing with it, because the two set different properties: [data-theme] flips color-scheme, which is what light-dark() resolves against, while your scope re-points the token names. Nest them in either order and both apply.

<div class="kiosk" data-theme="dark">
  <!-- dark palette, kiosk sizing -->
</div>
Light

Account balance

Дансны үлдэгдэл

Dark

Account balance

Дансны үлдэгдэл

Those two panels are the same page in the same document. Theming & dark mode goes through the mechanism.

Opt into a layer deliberately

Sometimes you want your own CSS to be overridable in turn. Put it in the overrides layer and it lands above every @arag layer but below anything unlayered — including the rest of your own app CSS.

/* app.css */
@layer overrides {
  button {
    border-radius: var(--radius-full);
  }
}

You want to be below unlayered code when you are writing broad, systemic corrections that individual screens should still be free to contradict: a house-style pass on top of @arag, a per-brand skin, a temporary compatibility shim during a migration. Putting them in overrides means a screen-specific rule can simply be written plainly and will win, instead of having to out-specify your shim.

If instead your CSS should beat everything, leave it unlayered. That is the default and it needs no syntax at all.

Why not !important

Because it is a one-way door. The only thing that beats an important declaration is another important declaration, so the first one in a codebase guarantees the second, and by the fourth nobody can predict what any rule does without reading all of them. Specificity stops being a useful tool at that point: it is still computed, but it no longer explains outcomes.

Layers give you the same thing the flag was being used for — deterministic precedence — without the ratchet. Precedence is declared once, in one line, in one file, and it is reversible: change the order and every rule moves with it. Nothing has to be rewritten.

The invariant is that the package contains zero of them, and it is greppable:

# must return nothing
grep -rn '!important' src/

It holds today. Wire it into CI as a failing check so it keeps holding — the whole value of the rule is that it is absolute, and an invariant that is only mostly true is just a preference.

# fails the build on the first one
if grep -rn '!important' src/; then
  echo "priority-raising declaration in src/ — see docs/layers.html"
  exit 1
fi

Note the one legitimate exception the rest of the industry recognises, which is why the check is scoped to src/: user stylesheets and accessibility overrides written by the person reading the page do get to use it, and should. That is a different origin entirely, and layers do not apply across origins.

Importing into an existing layered codebase

If the consuming application already uses layers, it will have its own order declaration, and dropping @arag in unlayered would put the package above all of it — the exact opposite of what you want. Assign it a position instead, by importing with layer():

/* app.css */
@layer vendor, base, components, pages;

@import url("../node_modules/@arag/css/src/index.css") layer(vendor);

Now the whole package occupies the vendor slot, wherever you put it in your own order, and your own layers relate to it exactly as you declared.

Caveat

Because each @arag file declares its own internal layer, importing into layer(vendor) nests them: they become vendor.reset, vendor.tokens, vendor.base and so on, in that same relative order, all sitting inside your vendor slot. This is almost always what you want — the internal ordering is preserved, the package stays self-consistent, and nothing of yours can accidentally land between @arag's reset and its base. It only matters if you intended to interleave your own CSS between two of the package's layers, in which case name the nested layer explicitly: @layer vendor.tokens { }.