This page was opened from file://. Chromium blocks the local CSS and JS for this site in that mode. Preview it over HTTP with pnpm dev or pnpm preview.

Skip to main content
dout.dev Frontend notes, design systems, and the sharp edges of shipping.

Article

/ Archive

CSS Properties Hierarchy (Or: How I Learned Custom Properties Can Replace Preprocessors)

I've been writing CSS since before border-radius was a thing, back when we used -moz-border-radius and prayed everything to work well.

CSS Properties Hierarchy (Or: How I Learned Custom Properties Can Replace Preprocessors)

Article content

I've been writing CSS since before border-radius was a thing, back when we used -moz-border-radius and prayed everything to work well.

I've seen preprocessors rise (LESS, Sass, Stylus, the holy war... what a mess) and watched CSS itself absorb every feature they offered, one spec at a time.

And through all of it, one thing never changed: the absolute chaos of an unorganized ruleset.

We all know about this: 400 lines of CSS where color: red is followed by position: absolute followed by three vendor-prefixed gradients followed by a font-size that overrides the one fifty lines up. It's not wrong, the cascade still resolves it, the browser still paints it, but it's unnecessary friction.

Every time you touch that file, you waste a few seconds scanning for what you need. Those seconds add up to hours. Those hours add up to bugs.

So I developed a system. A hierarchy that mirrors the browser's own rendering pipeline: outside-in, layout-first, paint-last.

The way I see it, every CSS ruleset tells a story, and the story has a logical narrative arc:

  1. Custom properties come first because they're the inputs. They're resolved by the cascade before any property does anything. You want them declared before they're consumed, and grouping them at the top makes the ruleset's "API surface" immediately visible.
  2. Position comes next because it takes the element out of flow before you even think about sizing or coloring it. Setting position: absolute after width is technically fine, but logically backwards, you're picking a layout strategy for the element before you decide its dimensions.
  3. Display right after position, because it determines the formatting context. Flex, grid, block, the element needs to know how to be a box before you can meaningfully set gap, place-content, or align-items.
  4. Opacity & visibility live here because they affect the box's presence without touching layout. They're the border between layout and paint.
  5. Box-model (width, padding, border, margin), the geometry. Inside-out: content size first, then padding, then border, then margin. box-sizing at the top of the section because it changes the math for everything below.
  6. Colors & background, the paint. Text color, background, shadows, filters. These trigger repaints but not re-layouts (mostly).
  7. Typography, because after you've painted the background, you render the text on top. font, line-height, text-align, white-space, text-shadow.
  8. Transforms & animations, the compositor stage. transform, transition, animation. These are the last thing that happens visually, but they're also the most performance-sensitive, so having them in one block makes it easy to audit will-change usage.
  9. Helpers, appearance, cursor, pointer-events. Utility properties that don't fit anywhere else.
  10. Pseudo-elements (&::before, &::after), they're part of the element's visual tree, nested in.
  11. Variants & pseudo-selectors, state changes: &.error, &[aria-hidden], &:hover. These override everything above, so they come last in the declaration block.
  12. Media queries and children, scoping. @media, & span, & > *. These are new contexts that restart the hierarchy. The comment says "repeat css hierarchy here" because each of these blocks is a fresh ruleset that follows the same order.

The order isn't arbitrary. It's the same order the browser processes properties: computed values → layout → paint → compositing.

Every time I break this order, I introduce a subtle cognitive tax on the next reader, myself included.

Every time I follow it, the ruleset reads like a coherent paragraph instead of a shopping list.

:root {
  --border-radius: 5px;
}

.element {
  /* css custom properties */
  --var--example: 1;
​
  /* position */
  position: absolute;
  inset: 0; /* top, right, bottom, left */
  z-index: 1;
​
  /* display */
  display: block;
  display: flex;
  place-content: center;
  place-items: center;
  justify-self: unset;
  gap: 1rem;

  opacity: 1;
  visibility: visible;
​
  /* box-model */
  box-sizing: border-box;
  width: 10rem;
  aspect-ratio: 16 / 9;
  padding: 1rem;
  border: 0.1rem solid black;
  border-radius: 0.4rem;
  margin: 1rem;
  outline: 0.3rem solid black;
  outline-offset: 0.3rem;
​
  /* colors & background */
  color: white;
  background-color: black;
  background-image: url();
  box-shadow: rgba(50, 50, 50, 1);
  filter: drop-shadow();
​
  /* text */
  font-family: 'Courier New', Courier, monospace;
  font-size: 1rem;
  font-weight: 700;
  line-height: normal;
  white-space: nowrap;
  text-align: center;
  text-shadow: none;
​
  /* transform & animations */
  transform: translate();
  transition: opacity 300ms ease-in, width 500ms linear;
  will-change: opacity, width;
  animation: test 300ms forwards alternate-reverse;
​
  /* helpers */
  appearance: none;
  cursor: pointer;
  pointer-event: none;
​
  /* pseudo elements */
  &::after {
  }
​
  /* variants & pseudo selectors */
  &.error {
    color: red;
  }

  &[aria-hidden=true] {
    display: none;
  }
​
  /* pseudo selectors */
  &:hover {
  }
​
  /* media queries */
  @media screen and (width >= 1024px) {
    /* repeat css hierarchy here */
  }

  /* ------------ children */
  span {
    /* repeat css hierarchy here */
  }
​
  input {
    /* repeat css hierarchy here */
  }
​
  > * {
    /* repeat css hierarchy here */
  }
}

Look at that ruleset. Now look at your last project's CSS. If the two don't look alike, you know what to do.

I'm not going to tell you this hierarchy is the One True Way™, I've been in this game long enough to know that CSS is a language, not a religion, and anyone who tells you there's exactly one correct way to order properties is selling something (probably a linter rule they wrote). But I will tell you this: having any consistent order is infinitely better than having no order at all. The specific convention matters less than the fact that you have one and you follow it.

What I will claim is that this specific hierarchy has survived three major migrations, six codebases, twelve team members with varying skill levels, and zero arguments. Because it's not "my opinion", it's the browser's rendering pipeline encoded as property order. You can't argue with the spec. Well, you can, but you'll lose.

The beautiful thing is that once you internalize this order, you stop thinking about it. You just write properties in the order they naturally fall in your head, and they happen to match the hierarchy. It becomes as automatic as indenting nested blocks or putting spaces after commas. The ruleset writes itself, and the next person who opens that file doesn't curse your name. That's the real win.

And yeah, about the preprocessor thing: I started using this hierarchy back when I was writing Sass. When CSS custom properties landed in browsers, I realized --my-var slotted perfectly into the top section of the hierarchy, custom properties first, always. Nesting (& at the bottom for variants, pseudo-elements, children) was already how I organized Sass. The hierarchy didn't change when the tooling changed. The language caught up to the discipline.

That's when it clicked: the hierarchy wasn't a Sass convention. It was a CSS convention that happened to work in Sass. And once native CSS nesting landed in 2023, I deleted my last @use 'sass', not because I hate Sass (I don't), but because the hierarchy made it irrelevant. Discipline beats tooling every time. Always has.

So here's my challenge to you: pick any ruleset in your current project. Reorder it using this hierarchy. Don't change a single value, just move the lines around. Then read it again. I bet you find a bug, or a duplicate property you didn't notice, or an override that doesn't do what you thought. The hierarchy surfaces that stuff because related things are next to each other instead of scattered across 80 lines.

And when you find that bug, think about this: the hierarchy found it, not a linter. A linter can tell you "duplicate property detected." The hierarchy tells you why it's a duplicate and which one wins.

Told you. CSS is a language, not a config file.

Discussion

Comments live in GitHub Discussions

Each thread is keyed to the source markdown entry for this post.