Controlling spacing with CSS adjacent sibling combinator
Whenever I need to distance one element from the other, instead of writing something naive like .far { margin-left: 10px; }
, I use the adjacent sibling selector, like so: .some + .far { margin-left: 10px; }
.
Writing selectors like this has a couple of advantages.
- It's robust. CSS above handles missing content well. If either element is not rendered, the selector won’t apply, and margins won’t render redundant whitespace.
- It's lean. We don’t have to introduce more CSS classes that describe component variations like
.user--has-bio
, and we don’t need to write JavaScript to manage them. - It's unambiguous. You don’t have to argue whether it should be margin-bottom on the former element: margin would always apply to the latter element.
- It maintains component boundaries. The selector
.a + .b
is clearly bigger than components A and B, so this selector needs to be placed higher in the hierarchy, whether it’s component C that contains both A and B, or page styles.
Imagine you have requirements for intricate whitespace handling in a user profile card component:
- If “Last online” info is missing, the title should still render in the same spot
- If the bio is present, there should be 30px whitespace between the avatar, the bio paragraph, and the follow button
- If the bio is absent, there should be 40px between the avatar and the follow button
- If bio and follow are both missing, there should be equal whitespace at the top and at the bottom of the card
Here's a demo of what we need:
Here’s the annotated CSS that achieves that with selectors that use the adjacent sibling combinator.
/* horizontal space between the avatar and the primary info */
.avatar + .primary-info {
margin-left: 20px;
}
/* keep title in place in last online info is missing */
.user-name + .title {
margin-top: 20px;
}
/* margin around bio if bio is present */
.bio + .follow,
.avatar-line + .bio {
margin-top: 30px;
}
/* slightly larger margin between follow button if bio is missing */
.avatar-line + .follow {
margin-top: 40px;
}
Using adjacent sibling combinator in component code
In line with the single responsibility principle in components, I generally try not to declare any whitespace at the top-level of components. However, we could also use the same approach and provide a sensible default, or control spacing between multiple instances of the same component:
/* produces whitespace between my component and any preceding element */
* + .my-component { margin-top: 10px; }
.my-component + .my-component { margin-top: 10px; }
In the two examples above I’d argue that these styles are a part of component definition, and do not violate the single responsibility principle. Writing down the same code with a preprocessor like Sass neatly places the two selectors into the same block as component-level styles.
.my-component {
* + &,
& + & { margin-top: 10px; }
// ..
// rest of component styles
}
Similar concepts
There’s other applications of this technique.
The example above has shown the adjacent sibling combinator in selectors to control whitespace, but really we can achieve any kind of conditional presentation. We could, for example, render lines between list items, or change background on a button in certain scenarios:
ul li + li { border-top: 1px solid #ccc }
.follow { background-color: #ccc; color: #fff; }
.send-message + .follow { background-color: transparent; color: #ccc; }
You also might find the general sibling combinator handy, .a ~ .b
. Unlike the adjacent sibling combinator, it doesn’t require that one element is directly adjacent to the other. General sibling combinator just ensures that one element follows the other.
/* color the callout only if there's an author block in the article */
.article .author ~ .callout { background-color: #fc0; }