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
| 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.
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.
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>Account balance
Дансны үлдэгдэл
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
fiNote 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.
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 { }.