web / css
My largest web app has one stylesheet of plain CSS, about 5,000 lines. It has no preprocessor, no Tailwind, and no CSS Modules. For years, a reader could not tell from a class name where its rule lived. The sheet mixed BEM blocks, snake_case blocks, and Tailwind-style utilities, and nothing checked which style went where. A style change on one page could break another.
I wanted one obvious way to write a rule. Cascade layers give the order, a lint in CI enforces the names, and custom properties give one scale.
Five layers
The first line of the sheet names the layers in order:
@layer reset, tokens, base, components, utilities;
@layer reset {
*,
*::after,
*::before {
box-sizing: border-box;
}
}
@layer tokens {
:root {
--spacing--medium: 1rem;
--font-size--small: 0.8rem;
--color--link: #002667;
}
}
@layer base {
a {
color: var(--color--link);
}
}
@layer components {
.card {
border-radius: var(--border-radius--base);
}
}
@layer utilities {
.mt-1 {
margin-top: var(--spacing--medium);
}
}
Each layer holds one kind of rule:
reset: what the app changes about every page, such asbox-sizing.tokens: the custom properties in:root.base: a rule that starts with a bare tag or an attribute, such asa,label, orinput[type="text"].components: a rule that starts with a class, for a repeated element with state, such as a button, a card, or a drawer.utilities: one property per class, for layout, spacing, and type.
A later layer beats an earlier one, whatever the specificity. So a
utility beats every component rule, and it needs no !important.
Before the layers, input[type="submit"] beat .button on every
submit input, because a type selector plus an attribute selector
outranks one class.
.hidden is the one utility that keeps !important. It must beat
every display utility, wherever that utility sits in the layer. A
phone-only utility that comes later would otherwise show a hidden
element on a phone.
What the order costs
A utility also beats a component's :hover rule. A gray link with
.text-gray loses the hover color that a:hover gives it, because
a:hover is in base. So a link that keeps its hover wears a small
component that sets both colors:
@layer components {
.link-muted {
color: var(--color--text-light);
&:hover {
color: var(--color--link-hover);
}
}
}
The same rule applies to every component that sets color on a link.
a:hover in base does not reach it, so the component sets its own
hover color.
A utility that a component rule always overrode did nothing. With layers, it began to win. I took each such utility out of its template before I moved the rules.
The lint
A cascade layer is only a convention until something fails the build. My CI already ran a command that reports selectors nothing renders. I gave it a second job: a naming rule for each layer.
components: a kebab-case block, with an optional__elementand an optional--modifier, such aschart-tip__label--bold.utilities: a short name with no__and no--, such asmt-1.- Every other layer: no class.
It fails on three more things:
- A top-level rule in
componentsthat does not start with a class. A rule for a tag or an attribute goes inbase. - An ID selector, in any layer. An ID outranks every class rule in its
layer, so a component class cannot override it. A template keeps an
ID that JavaScript reads, and the sheet styles a class on the same
element. A
#inside an attribute value, as in[href="#top"], names no ID. - A rule outside every layer. An unlayered rule beats every layered rule, so one added at the end of the file would override the utilities.
The lint parses the sheet and resolves the nesting. A nested rule is not checked as top-level, because it starts with its parent. The rule of each layer is a regular expression or a substring check:
// componentRE matches a component class: a block, an optional
// __element, and an optional --modifier.
var componentRE = regexp.MustCompile(`^([a-z][a-z0-9]*(?:[-_][a-z0-9]+)*)(?:__[a-z0-9-]+)?(?:--[a-z0-9-]+)?$`)
The block pattern still accepts an underscore, so a snake_case block passes for now. A flag lists the snake_case blocks left, and I rename one area at a time, with its templates.
One scale
The utility layer once held the same values as the tokens under a
second name. .mt-1 was 1rem, and --spacing--medium was 1rem. A
designer who moved one did not move the other.
Now every utility spends a token:
@layer utilities {
.mt-1 {
margin-top: var(--spacing--medium);
}
.gap-2 {
gap: var(--spacing--x-large);
}
.font-medium {
font-weight: var(--font-weight--medium);
}
}
No selector changed, so no template changed. Two steps had no token:
2rem for spacing and 500 for weight. A new step goes in :root
first, and then a utility spends it.
A value off the scale keeps a literal and says why in a comment. A
size such as w-* or h-* keeps a literal too, because a size is not
a step of spacing.
@media cannot read a custom property. So the two breakpoints are
literals.
Nesting
The sheet uses native CSS nesting. There is no &--suffix
concatenation and no @extend. A widget that shares a base wears
both classes in the markup.
A new element inside a block is a nested selector under the block,
not a new __ name:
@layer components {
.card {
padding: var(--spacing--medium);
.title {
font-size: var(--font-size--medium);
}
}
}
esbuild flattens the nesting at build time for a list of browser targets. So the browser floor is that list, not the engines that shipped native nesting.
How I check a change
CI cannot see what a page looks like. Each step above moved rules
between layers and could change a page. So for each step, an
agent took screenshots with browse at
phone, laptop, and desktop widths, and compared them with the same
pages from main.
Two pages changed, and I kept both changes. A row of buttons on one
tab became a flex row with a gap, because a base rule no longer made
it a block. A link in a dropdown menu kept its text color under the
pointer, as the button items in the same menu did.