Defining element spacing with CSS custom properties

A simple and effective approach to creating reliable and ambitious page layouts

The traditional web page is a set of sections/blocks/modules stacked on top of each other, with varying margins between elements depending on their type.

Simple stacked section example, with 120px between them (example: marifulness.com)
Note

Many interesting design patterns go beyond this box model and include sections overlapping and interacting with each other. You can apply the principles of this guide for some of these, but they aren't my focus here.

You could model this in many ways, and I've tried several over the years. In the end, it'd always become a hot mess that was hard to reason about.

With CSS custom properties, though, you specify that every child in a parent has a set margin-top based on a mg-top variable, and store all design rules in a single, concise CSS file.

/* Parent sets the margin of every child, except the
first, which should be at the top of the parent */
.layout-root > *:not(:first-child) {
  margin-top: var(--mg-top, 1em); /* 1em as the default, could be 0 */
  
  
  /* or, if you want to specify mg-top as a simple number and want to convert it to rems: */
  margin-top: calc(calc(var(--mg-top, 1em) / 16) * 1rem);
}


/* Then, each individual section inside the layout can define their own top margins */
.faq {
  --mg-top: 3rem;
  
  --mg-top: 60; /* or number only if using the calc() route above */
}
.project, .about {
  --mg-top: 120;
}
.highlighted-cta {
  --mg-top: 180; /* need more prominence? higher margin! */
}

This works flawlessly with media queries:

@media (min-width: 1024px) {
  .highlighted-cta {
    --mg-top: 240;
  }
}

And creates the perfect framework for rule-based design or Content Responsive Design:

.cta {
  --mg-top: 60;
}

/* CTAs coming after a pricing table should be closer */
.pricing-table + .cta {
  --mg-top: 20;
}

.project {
  --mg-top: 120;
}

/* No need to separate projects all that much */
.project + .project {
  --mg-top: 90;
}

I recently up with this while building Marifulness.com, it's another fruit of my exploration of leaning more heavily on CSS's capabilities. It's one of those things that you miss when going too heavy-handed on Tailwind CSS. Honestly, I think I won't use Tailwind again, just copy its pattern for useful token-based classes.

As I was exploring Andy Bell's CUBE CSS as an alternative to utility-only frameworks, I came across his approach to "flow" (in this CSSTricks video), which is essentially the same solution I arrived at. As expected, he was more insightful than me, though, and revealed:

  1. the need for excluding the first child to ensure safe layouts - *:not(:first-child) (in his case, * + *)
  2. and the possibility of using this as a general flow approach anywhere in your layouts - even deeply nested paragraphs in a card component, for example

With 2, the following is possible:

<main class="flow">
  <!-- Project is also a flow container -->
  <div class="project flow">
    <h2>Project name</h2>
    <p>My project description ...</p>
    <img src="..." />
  </div>
</main>

<style>
  .flow > *:not(:first-child) {
    margin-top: var(--mg-top, 1em);
  }
  .project > p {
    --mg-top: 0.5em;
  }
  .project > img {
    --mg-top: 3em;
  }
</style>
<div class="article-content flow">
  <h1>Full blog post</h1>
  <p>...</p>
  <p>...</p>
  <img src="..." />
  <h2>...</h2>
  <blockquote></blockquote>
  <ul>
    <!-- ... -->
  </ul>
</div>

<style>
  .flow > *:not(:first-child) {
    margin-top: var(--mg-top, 1em);
  }
  .article-content > blockquote {
    --mg-top: 2em;
  }
  .article-content > img {
    --mg-top: 1.5em;
  }
  .article-content > h2 {
    --mg-top: 3em;
  }
  .article-content > ul + p {
    --mg-top: 1.5em;
  }
  /* And whatever other spacing rules you may have for rich text */
</style>

If you have suggestions, reach out at meet@hdoro.dev or hdorodev :)