Getting started
Install is a copy or an npm install, and consuming it is one
<link>. There is nothing to compile, nothing to configure and no CLI. A
semantic HTML document is styled the moment the stylesheet loads.
Install
Two paths. Pick whichever matches how the project already handles vendor code.
Via npm
The package is @arag/css. It has no dependencies and no install scripts.
npm install @arag/cssThen link the entry point straight out of node_modules, or copy it to wherever
the project serves static assets from.
<link rel="stylesheet" href="node_modules/@arag/css/src/index.css">Vendored
Copy src/ into the project and link it. This is the right choice for the kiosk
app and for print-adjacent work, where there is no package manager in the loop at all.
cp -r arag/src public/vendor/arag
<link rel="stylesheet" href="/vendor/arag/index.css">Either way: no build step, no config file, no CLI, no watcher, no class scanner. The files
you link are the files that were written. What you read in src/ is exactly what
the browser parses, so devtools shows you real source lines and not a compiled artefact.
The link tag
This is a complete document. There is not one class on anything in it, and it is correctly styled — typography, spacing, colour, focus ring, dark mode and the Cyrillic font stack all come from the single link.
Account balance
Payment details were updated successfully.
Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.
Body of the panel above
<h1>Account balance</h1>
<p>Payment details were updated successfully.</p>
<p lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</p>
<button type="button" lang="mn">Илгээх</button>The panel above is rendered inside this page, so its heading is an h3 rather
than the h1 a standalone document would use. Everything else is identical. The
whole file:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Balance</title>
<link rel="stylesheet" href="node_modules/@arag/css/src/index.css">
</head>
<body>
<h1>Account balance</h1>
<p>Payment details were updated successfully.</p>
<p lang="mn">Төлбөрийн мэдээлэл амжилттай шинэчлэгдлээ.</p>
<button type="button" lang="mn">Илгээх</button>
</body>
</html>Two things in that head are load-bearing and easy to drop. The viewport meta is what makes
the fluid type scale resolve against the real device width instead of a fictional desktop
one. viewport-fit=cover is the part of it that is easiest to leave off, because
nothing breaks visibly until you test on an actual phone: it is what lets the page paint
into the notch and home-indicator area instead of stopping short of it, and it is what turns
on the four env(safe-area-inset-*) reads behind
--safe-block-start, --safe-block-end, --safe-inline-start
and --safe-inline-end in tokens.css. Leave it off and those four
tokens are permanently 0px, and a bottom-sheet dialog or a bottom toast sits
flush under the home indicator instead of clear of it. The package uses those tokens to keep
its own components — the sheet, the full-screen dialog, the bottom toast placement — off the
unsafe edge, but it cannot do that for chrome that is yours: any bar or button you anchor to
a screen edge needs to read the same safe tokens itself, or it inherits the same problem this
meta exists to fix. This docs site does not set viewport-fit=cover on its own
pages: without it the browser letterboxes the page inside the safe area, so the sticky
masthead and sidebar never reach a display cutout in the first place, and setting
viewport-fit=cover is exactly what would create that obligation. And lang
matters: it is on the root element for the document as a whole, and repeated as
lang="mn" on each Mongolian run, which is what the font stack and the
line-breaking key off. See bilingual & Cyrillic.
What you get immediately
base.css styles bare HTML elements. It adds no classes and asks for none, so a
semantic document is already a styled document. Everything below is covered with zero markup
changes.
- Document and body copy — font stack,
color-scheme, measure, line height, paragraph rhythm. - Headings —
h1throughh6on the fluid type scale, each with its own leading and tracking. - Links — colour, underline offset, hover and visited states.
- Lists —
ul,ol,dl, including nesting. Lists carrying an explicitrole="list"are stripped bare instead, so the semantics stay announced. - Blockquote.
- Code and
<pre>— mono stack, sunken background, border, and overflow that scrolls rather than bursting the column. - Tables — caption, header, borders, cell padding. Cells marked
data-numericswitch to tabular figures and end alignment, which is what makes a column of amounts line up. - All form controls —
inputin every type,textarea,select,button,fieldset,legendandlabel, with disabled and invalid states. :focus-visiblerings — applied once, broadly. No default:focusring is added or removed, so keyboard users get a ring and pointer users do not.::selection— themed, and legible in both themes.<details>and<summary>.<dialog>— surface, radius, shadow, and a themed::backdrop.
Here is a fragment using several of those at once. Again, no classes.
| Reference | Amount |
|---|---|
| MNT-4471 | 1250000 |
| MNT-4472 | 98400 |
Хэрэглэгчийн тохиргоо
Гуйвуулга баталгаажлаа
Markup of the panel above
<table>
<caption>Transfers</caption>
<thead>
<tr><th scope="col">Reference</th><th scope="col" data-numeric>Amount</th></tr>
</thead>
<tbody>
<tr><th scope="row">MNT-4471</th><td data-numeric>1250000</td></tr>
</tbody>
</table>
<details>
<summary lang="mn">Хэрэглэгчийн тохиргоо</summary>
<p lang="mn">Гуйвуулга баталгаажлаа</p>
</details>
<label for="phone" lang="mn">Утасны дугаар</label>
<input id="phone" type="tel" inputmode="tel">What is deliberately not covered
Scrollbars are left alone. Every custom scrollbar we have shipped has been worse than the
native one on at least one of the four target browsers, and on the kiosk build the native
one is what the touch layer expects. There is no scrollbar-width,
scrollbar-color or ::-webkit-scrollbar rule anywhere in the
package.
There are also no component classes yet. src/components/ and
src/utilities.css are empty — the layers are declared and reserved, but nothing
is in them. Until they land, build components in your own app CSS, unlayered, and let them
sit above the system. That is the same mechanism described in
cascade layers, so nothing changes when the real components arrive
except that you get to delete code.
Importing individual files
Every source file wraps its own content in its own @layer block.
reset.css declares @layer reset, tokens.css declares
@layer tokens, base.css declares @layer base. None of
them relies on index.css having run first, so each file is correct when linked
on its own.
The realistic use case: an application that already has its own component CSS and does not
want element defaults imposed on it, but does want the @arag palette, spacing
scale and type scale so it matches the rest of the product family. Link
tokens.css alone and use the custom properties in the rules you already
have.
<link rel="stylesheet" href="node_modules/@arag/css/src/tokens.css">/* your existing CSS, unchanged apart from the values */
.card {
padding: var(--space-5);
border-radius: var(--radius-md);
background-color: var(--color-surface);
color: var(--color-text);
}Because tokens.css is also where color-scheme and the
light-dark() pairs live, this gets you working dark mode without adopting
anything else.
The subpaths are declared in package.json, so bundlers and bare
@import specifiers both resolve them:
"exports": {
".": "./src/index.css",
"./reset.css": "./src/reset.css",
"./tokens.css": "./src/tokens.css",
"./base.css": "./src/base.css",
"./src/*": "./src/*"
}| Specifier | Resolves to | Use when |
|---|---|---|
@arag/css | src/index.css |
You want the system. This is the default and what almost everything should use. |
@arag/css/tokens.css | src/tokens.css |
You want the values only, for your own component CSS. |
@arag/css/reset.css | src/reset.css |
You want a token-free reset, standalone. |
@arag/css/base.css | src/base.css |
Rarely on its own — its var() references need the tokens file to
resolve. |
Your first override
All six of the system's layers sit below unlayered CSS. Your application stylesheet is unlayered unless you deliberately put it in a layer, so it already outranks the whole package. Overriding is therefore just writing the rule you would have written anyway.
Say base.css gives buttons a neutral surface, and the portal needs its primary
action brand-filled. Before, in the package:
/* @arag/css/src/base.css — inside @layer base */
button {
background-color: var(--color-surface);
color: var(--color-text);
}After, in your own stylesheet, loaded after the @arag link:
/* app.css — unlayered */
button {
background-color: var(--color-brand-solid);
color: var(--color-text-on-solid);
}Both rules are a bare type selector — specificity (0,0,1) on each side. Yours
wins anyway, because unlayered declarations outrank every layered one no matter what the
specificity says. You did not need an extra class, a doubled selector, an :is()
wrapper or a parent scope, and you did not need to know anything about how
base.css was written.
This is the whole reason the package contains no priority-raising declarations: there is
nothing here for you to fight, so you should never need one either. If you find yourself
reaching for !important against @arag, treat that as a bug report
rather than a solution. Cascade layers covers the mechanism, the
one trap that has actually bitten this codebase, and the recipes for changing tokens rather
than rules.
File map
| File | Layer | Does |
|---|---|---|
src/index.css | — | Declares the layer order, then imports the rest. The entry point. Has no declarations of its own. |
src/reset.css | reset |
Removes browser default rendering. No var(), no colours, no sizes —
token-free by design, so it is correct standalone. |
src/tokens.css | tokens |
Every literal value in the system, as custom properties on :root, plus
color-scheme and the [data-theme] switches. |
src/base.css | base |
Token-aware element defaults — body, headings, links, lists, tables, form controls, focus, selection, details, dialog. |
src/components/ | components |
Empty. One file per component, next session. The layer is already declared and reserved. |
src/utilities.css | utilities |
Empty. A static, hand-picked class list, next session. No generator, no arbitrary-value syntax. |
| — | overrides |
Declared, never written to by the package. Reserved for the consuming application's escape hatch. |
Verify your install
Load the page, open devtools, and paste this into the console. It asks the browser what a token actually resolved to, which is a stronger check than seeing that the page looks roughly right.
getComputedStyle(document.body).getPropertyValue('--color-brand-600').trim()A working install returns an oklch(...) string. On this page, right now, that
value is . An empty string means the custom
property is not defined on the document at all, and that has exactly two causes: the
stylesheet never loaded, or it loaded from the wrong path. Check the Network panel for a 404
on index.css, and remember that the @import rules inside it resolve
relative to index.css itself, not to your HTML file — so moving
index.css out of src/ without moving its siblings breaks it
quietly.
To check the whole chain rather than one property, read one token from each part of the scale at once:
['--color-brand-600', '--space-4', '--text-base', '--radius-md']
.map(function (t) {
return t + ': ' + getComputedStyle(document.body).getPropertyValue(t).trim();
})
.join('\n')Any empty value in that list is the same diagnosis. If all four resolve, the tokens layer is live and everything above it is reading from it.