Skip to main content
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Charvi Dhoot
Ben Nadel at CF Summit West 2024 (Las Vegas) with: Charvi Dhoot

Using CSS Gap To Control Margins In Website Copy

By
Published in Comments (2)

For the next update to my Incident Commander triage app, I was thinking about adding the CSS Open Props project from Adam Argyle. I've looked at Open Props a bit in the past; but, I never looked at Adam's "Built With" section before. And, upon closer inspection, I saw something that kind of blew my mind: Adam is using the CSS Grid layout to render website copy. And, more to the point, he's using the CSS gap property to control the margins in between the block-level copy elements.

Run this demo in my JavaScript Demos project on GitHub.

View this code in my JavaScript Demos project on GitHub.

This isn't the first time that Adam's Open Props implementation details brought forth a moment of enlightenment (see my post on using :where() to reduce specificity issues); but, CSS margin management has been a huge point of insecurity for me over the years; and, has been front-of-mind for me as of late while I'm fleshing out my Incident Commander application.

The basic premise of this technique is that within a host element you:

  1. Remove all the default margins from the block elements so that the elements butt-up against one another.

  2. Set display: flex (or grid) on the host element and then use gap to control the spacing in between each block element.

For example:

article {
	display: flex ;
	flex-direction: column ;
	/* 20px spacing in between block elements. */
	gap: 20px ;

	/* Remove default margins on block elements. */
	& h1, h2, h3, p {
		margin: 0 ;
	}
}

To see this in action, I've created a demo using Alpine.js that binds a Range Input to the style attribute of an <article>. As the range is adjusted, the gap property on the article is updated dynamically:

<!doctype html>
<html lang="en">
<head>
	<link rel="stylesheet" type="text/css" href="./main.css" />
	<style type="text/css">

		article {
			display: flex ;
			flex-direction: column ;
			/*
			* Using GAP in the COLUMN layout creates a sort of simulated margin collapsing
			* that will provide space between all the block elements.
			*/
			gap: 0rem ;

			/*
			* Remove the default margins from block elements. These will be managed by
			* the GAP on the host element.
			*/
			& h1, h2, h3, p {
				margin: 0 ;
			}

			/* A little extra top-gap for sub-headings. These are ADDED to the gap. */
			& h2 {
				margin-top: 0.2rem ;
			}
			& h3 {
				margin-top: 0.1rem ;
			}
		}

	</style>
	<script type="text/javascript" src="../../vendor/alpine/3.13.5/alpine.3.13.5.min.js" defer></script>
</head>
<body x-data="{ customGap: '0' }">

	<form>
		Gap:
		<input type="range" x-model="customGap" min="0" max="4" step="0.1" />
		<span x-text="customGap"></span>rem
	</form>

	<!-- Using Apline.js to bind the range input to the inline style. -->
	<article :style="{ gap: `${ customGap }rem` }">

		<h1>
			Using CSS Gap To Control Margins In Website Copy
		</h1>

		<h2>
			Oratio vel iudicium indico quamvis
		</h2>

		<p>
			Lorem ipsum dolor sit amet aufero tu, quis inferus os. Meus credo post niger hiems. Proprius labor, quotiens inter divitiae, iuro sanctus dexter spatium. Aliquis quaero apud caecus aevum. Aureus frons, tandem post fama, traho candidus tuus auxilium. Bos curo foedus senex. Aliquis impleo quicumque negotium ve ille laus.
		</p>

		<h2>
			Forum vel saeculum sto sicut
		</h2>

		<!-- ... truncated for blog post ... -->

	</article>

</body>
</html>

Now, as the range input thumb slides from 0 to 4 with increments of 0.1, we can see that the space in between the copy blocks expands:

Screen recording of the input range slider being manipulated. As the range is increased, so is the space in between all the block-level elements on the page.

Notice that as I move the input range to the right, the space in between the block elements is increasing. This is because I'm dynamically setting the gap property on the <article> based on the current value of the input.

Applies To Rendered Elements Only

One really exciting aspect to this approach is that it only applies to rendered elements. Historically, I've used margin-control techniques that leverage CSS selectors like :first-child, :last-child, and :first-of-type. But, these techniques fall down the moment you have non-rendered elements in your content. CSS selectors doesn't care if the elements visible to the user, they only cares if they're in the Document Object Model (DOM) tree. This becomes particularly problematic the moment you have a <template> element, or an inline <style> or <script> block.

By using layout-based strategy (ie, CSS Flexbox, CSS Grid) for spacing content, you're specifically targeting rendered elements; and, implicitly ignoring non-rendered elements.

To see this in action, let's look at a <fieldset> element. The <fieldset> is tricky because the first element is the <legend> which would become the target of a :first-child selector. But, since we're using display: flex, we can essentially "skip over" the <legend> and apply the gap to the rest of the child elements:

<!doctype html>
<html lang="en">
<head>
	<style type="text/css">

		fieldset {
			display: flex ;
			flex-direction: column ;
			gap: 1rem ;

			& input {
				display: block ;
				margin: 0 ;
			}
		}

	</style>
</head>
<body>

	<fieldset>
		<legend>
			This is not an inline element
		</legend>

				<template>
					<!-- This is not a rendered element. -->
				</template>
				<template>
					<!-- This is not a rendered element. -->
				</template>

		<input type="text" />

				<template>
					<!-- This is not a rendered element -->
				</template>

		<input type="text" />
		<input type="text" />

				<style>
					/* This is not a rendered element. */
				</style>
	</fieldset>

</body>
</html>

As you can see, we're mixing both rendered and non-rendered elements together at the same level. And, when we run this code in the browser, we get the following output:

Screenshot of the fieldset showing three evenly-spaces Inputs.

Notice that the three <input> elements are evenly spaced within the parent container (<fieldset>). And, that none of the <legend>, <template>, or <style> elements contributed to the overall gap behavior.

This is so exciting! I feel like maybe this just completely revolutionized the way that I think about content layout. For years, I've frowned upon any CSS reset that removes all of the default margins. But, this may have just turned that opinion upside-down.

Want to use code from this post? Check out the license.

Reader Comments

15,902 Comments

I've been continuing to go over this in my mind and the place where I keep getting stuck are the native elements that have special display properties. Specifically the li element (though this would also apply to other elements like th and td).

It's one thing if you take an element that has a native block display and change it to be flex or grid and apply the gap for spacing. But, if you change an li from display:list-item to display:flex, it's a breaking change to the way the item renders. It will lose its marker.

Which begs the question, how do you handle:

<li>
	<p>Some text</p>
	<p>Some text</p>
	<p>Some text</p>
</li>

I can't use the gap trick on the li directly as it will break the behavior of the list-item. I could wrap the inner-content of the list item like:

<li>
	<div class="spaced">
		<p>Some text</p>
		<p>Some text</p>
		<p>Some text</p>
	</div>
</li>

And that would work. But then I can't use this technique anywhere that open-ended user-provided / user-generated content is in place (since the user won't know about these constraints or about the need to wrap the li content in a utility host).

And, again, the same thinking would also apply to a td element, where a table cell might have any other type of content contained within it.

Which begs the question, if I'm in a context in which user-provided content is a possibility, do I just have to rely on more "natural" layout techniques (ie, rely on native margin collapsing and the whole :last-child type thing)? Does having to contend with user-provided content make this more clever technique mostly moot?

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel