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:

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.

It fails on three more things:

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.

← All articles