Implementing baseline rhythm in CSS

Written by Jan Dudek

Jan Dudek’s photo

Implementing baseline rhythm in CSS

Vertical rhythm is a typographic concept that’s so often misunderstood by front-end engineers.

By aligning type to a vertical grid, designers can make their work look harmonious and clean. And by having the same visual rhythm implemented correctly, front-end architects can achieve consistent, good-looking results more easily and in shorter time. All that without the need for designers’ input in the process.

In this article, I’ll help you lay foundations for the proper implemenation of a vertical rhythm in CSS. Let’s start by clarifying what we won’t be working on.

The concept has been present on the web for years. It introduces a common line height value (or its multiple) that’s used for all elements, including their paddings and margins, occasionally taking border widths into the equation.

In that scenario, according to the CSS standard, the type gets vertically aligned in the middle of two grid lines. But insert two differently formatted elements next to each other and they’ll seem out of phase.

Baseline rhythm explained

There is an alternative approach that provides consistent results regardless of the used font settings. That’s aligning the baseline of the text. By using this technique, all type—regardless of its size—lies on the same grid line.

CSS doesn’t provide any handy tools for that, so we’ll have to make them manually using a few tweaks. There are two things to find out:

  1. How far the content has to be shifted.
  2. How to efficiently shift it by that amount.

Determining the shift

Razvan Onofrei wrote an excellent article that explains this part.

In short, the height of a capital letter above the baseline is called the cap height (and that’s what browsers will center out between the grid lines automatically). What we need to do is to shift it by half the difference between line height and cap height.

A cap height is a property of the font that’s being used. It can be determined experimentally by fiddling with the values until our type is properly aligned with the grid.

Here’s an excerpt from our stylesheets based on the idea:

$line-height: 24px;

$font-stacks: (
  s:  $font-stack-text,
  m:  $font-stack-text,
  l:  $font-stack-display,
  xl: $font-stack-display

$font-sizes:  (s: 13px, m: 15px, l: 19px, xl: 27px);
$cap-heights: (s: 0.8,  m: 0.8,  l: 0.68, xl: 0.68);

// Accepts `s`, `m`, `l`, or `xl`
@function rhythm-shift($size-name) {
  $font-size: map-get($font-sizes, $size-name);
  $cap-height: map-get($cap-heights, $size-name);

  $offset: ($line-height - $cap-height * $font-size) / 2;
  @return round($offset);

Applying the offset

Once we know how far the text is to be shifted, we need to decide how to do it reliably. Retrospectively, we’ve approached it in a few ways.

Solution 1. Take advantage of relative positioning.

Use the top property to shift the content without affecting the context.

$offset: rhythm-shift(m);

.rhythm-m {
  position: relative;
  top: $offset;

👉 See the example.

This might be the easiest option, but you may encounter at least two problems with this approach along the way:

  1. position: relative affects the stacking of elements. If two elements overlap, the one positioned relatively is displayed on top. At some point, this might involve undesired manual z-index tweaks.
  2. position may be needed for different purposes, e.g. for absolutely positioned content.

This solution surely works great when the codebase feels small and simple. We’ve eventually ruled it out, though, when our app became more complex and the architecture required more scalability.

Solution 2. Use positive top padding and negative bottom margin.

This is the approach suggested in the article I mentioned:

$offset: rhythm-shift(m);

.rhythm-m {
  padding-top: $offset;
  margin-bottom: -1 * $offset;

👉 See the example.

The top padding shifts the content just as we need. The negative bottom margin is used to compensate for this offset. It’s important to always use margins in one direction only, e.g. only bottom margins. Otherwise they collapse and break the whole system.

The biggest drawback of this solution is how it quickly adds complexity. Take the case from our our app, where you’ll find utility padding classes: pt-1 adding 24px top padding, pt-2 48px (two line heights), and so on. Using these classes together either requires involving additional HTML containers or results in overuse of Sass features for generating all possible cases. Both cases make the work cumbersome and impossible to phase out easily later on.

Solution 3. Use positive top margin and negative bottom margin.

This is the final solution that we ended up with:

$offset: rhythm-shift(m);

.rhythm-m {
  margin-top: $offset;
  margin-bottom: -1 * $offset;

👉 See the example.

As in the previous approach, the top value—margin this time—is compensated with negative bottom margin.

What about collapsing margins?

Fortunately, there’s a neat trick passing by this issue. But first, let me remind how collapsing works in presence of positive and negative margins:

  • Given two positive margins, the bigger one wins. For margin-bottom: 30px and then margin-top: 20px, the final space between these elements is 30px.

  • Given two negative margins, again, the lower (meaning the more negative) wins.

  • Given a positive and a negative margin, they sum up. For margin-bottom: 30px and margin-top: -20px, they result in 10px of whitespace.

The last point means that if we always alternate positive and negative margins, their values will be summed and our type will stay aligned to the grid lines.

To maintain this guarantee, we decided not to use margins anywhere in our app with the expection for the rhythm system. It’s not a big loss, though—uncurated margins aren’t always predictable anyway.

Overcoming margin overflow

Margins in CSS have one more nasty feature: if an element doesn’t have a border nor padding, and its first child has a margin, that margin will flow out of the parent. This becomes an issue when the parent has a background. That background will start wherever the child appears, and not within the parent.

There are two ways to go about it.

  • Either use overflow: hidden to force the margins to be contained within the parent,
  • or add padding-top: 0.1px, which is a trivial hack. The value is too small to be actually rendered, but it’s enough to keep the child within the parent container bounds.

👉 See the final example.

That’s it. We’ve been successfully using this system in our app for months now.

Even though CSS does’t provide such a system out of the box, having laid the right foundations for our framework made it perfectly achievable.