🅭

Why HTML table cells won’t fit into your CSS baseline grid

A baseline grid design is a fancy way of describing a page design laid out like on lined paper sheets. (The lines are not visible, of course.) It’s the art of ensuring your design and text maintain a rhythm and the same visual pacing throughout the page by using consistent line heights and spacing.

It just takes a bit of practice to stay within the lines when you’re writing on paper It’s also easy with CSS, until you meet rigid pixel-design elements like figures, images, and tables. In this article, I’ll demonstrate how to align the text in your tables with your baseline grid. I’ll also explore why it’s so difficult with tables in particular. It should easily fit with some adjustment, but the eccentricities of CSS Table Layout will fight you to the bitter end. It’s just rows of text, right?

You can quickly get most of your text to follow a baseline grid by setting the top and bottom margins of your paragraph, list, and other phrasing content to the same value as your line-height. CSS collapses overlapping margins. Using the below stylesheet, the distance between two neighboring p elements is 1,5 em not 3 em. That’s it. Here’s a quick cheat sheet to get you started: You’d normally set the document’s line-height on the document root element and not on individual elements.

p, ol, ul
{
  line-height: 1.5em;
  margin-top: 1.5em;
  margin-bottom: 1.5em;
}

Headlines and content larger than one line require some more planning to account for. There are many guides and different techniques that go deeper into baseline grids. However, I want to focus on a difficult outlier: the CSS Table Layout module.

We all stopped laying out our designs with HTML tables in the early 2000s. The many accessibility problems were never enough to kill the practice completely, but the mobile-first world has now firmly put the last nail in table designs. It’s now even a social faux pas in webdesign circles to even prototype using tables!

Except that tabular data is still best presented using a table. Datasheets, product details, research papers, news articles, and even press releases include lots of tables. We still need tables! Unfortunately, the CSS Table Layout module is stuck in the past and many CSS innovations have left it behind (or worse, undefined).

So, what’s the problem then? The fundamental problem we want to overcome is that borders add to the line height. You need to account for, say 1 px, worth of borders between each table cell. In this article, I’ll only discuss the collapsed-borders mode (border-collapse: collapse). The separated-borders mode is similar, but it isn’t suitable for the type of grid-based layout discussed here.

In the regular CSS Box Model, you’d simply remove the same number of pixels from your top and bottom margins. This does not work for table cells, however. The cells don’t have any margins. Nevertheless, here’s a quick example of that technique. I’ll use CSS variables (defined with the double-dash prefix) to avoid repeating magical numbers.

p
{
--text-height: 1.5em;
  line-height: var(--text-height);

--vertical-margin: var(--text-height);
  margin-top: var(--vertical-margin);
  margin-bottom:  var(--vertical-margin);
}

p.with-border
{
--border-thickness: 1px;
  border: var(--border-thickness) solid black;

--bordered-vertical-margin: calc(
    var(--text-height) - var(--border-thickness)
  );

  margin-top: var(--bordered-vertical-margin);
  margin-bottom:  var(--bordered-vertical-margin);
}

It’s a bit messy, but it’s easy enough to understand and implement. When you add 1 px on the top and bottom; then you need to remove 1 px from the margin as well. Likewise, if you add 4 px internal top and bottom padding inside the border; then you remove the same pixels from the margins.

Tables consist of many rows, each with its own collapsed border. The collapsed-borders mode disables cell spacing (margins), and collapses intersecting borders into one. You get a 1 px border between two cells even when each cell is styled with 1 px on all sides. You can try the same technique as above, but it doesn’t work well with an undefined number of table rows. No, CSS Counters won’t save you. You can’t use them as an input to calc(). At some point, you’d run out of vertical margin to subtract pixels from.

Tables are uniquely difficult to style in CSS because of the many limitations of the CSS Table Model. The CSS Table Model specification (draft) says, In CSS […] the height of a cell box is the minimum height required by the content. In practice, it means that the height of a table cell can’t be constrained. Furthermore, a table cell’s height is determined by: line-height × n-lines of text. The border, height, and padding properties can also increase, but not decrease the cell height.

One way to tackle it is to remove 1 px from the top margin (to account for the topmost border), and then to reduce the line-height of each cell by 1 px (to account for each cell’s bottom border). That works fine as long as no cell wraps onto multiple lines. Then you’ll be 1 px short per wrapped line in your table. You can’t know beforehand how many lines your text will wrap to on all devices, so this is an unstable approach. I’ll get back to this technique later in the article.

However, a modified version of the line-height adjustment technique will get the job done. We only need to reduce the line-height by a pixel per cell. You can be a bit cheeky and only reduce the line height of the first line in each cell! Luckily, the first line of each cell gets pushed down 1 px, and luckily CSS has a ::first-line pseudo-selector. There are quite a few caveats to deal with still, but here’s the solution so far:

table
{
  border-collapse: collapse;
--border-thickness: 1px;

--text-height: 1.5em;
  line-height: var(--text-height);

--vertical-margin: var(--text-height);
  margin-top: calc(
    var(--vertical-margin) - var(--border-thickness)
  );
  margin-bottom: var(--vertical-margin);
}

table th,
table td
{
  border: var(--border-thickness) solid black;
}

table th::first-line,
table td::first-line
{
  line-height: calc(
    var(--text-height) - var(--border-thickness)
  );
}

The above works well in Chrome 97 and Safari 15, but not in Firefox 96. The key difference between the two is that Chrome resizes the table cells based on the actual contents and collapses its height to 1.5 em - 1 px. Firefox sizes the table cell based on the parent row’s line height (1.5 em) even though the content is 1 px shorter.

The CSS Working Group decided in January 2021 that Chrome’s behavior is correct. Firefox has yet to fix its implementation to match Chrome’s behavior, however. Unfortunately, even if Firefox caught up with the competition, the above technique wouldn’t offer a pixel-perfect solution.

The ::first-line pseudo-selector isn’t as useful in practice as it might appear. Besides not working as expected in Firefox, it won’t work with all content in Chrome and Safari. The pseudo-selector can only select from its first directly descendant text nodes. E.g. the p::first-line of <p>I <em>like</em> CSS</p> will always select “I”; as that’s the first text node. (A new element marks the start of a new node.) That’s fine if you know beforehand that your tables will never contain any flow/formatting elements. However, you know you’ve strayed off the right path when you’re starting to impose restrictions on the content.

So, while the above technique can yield pixel-perfect results; it’s too poorly supported and unpredictable. I recommend compromising in a way that produces predictable results in all browsers under the same conditions. That doesn’t leave many options with the limited set of attributes supported in the table model.

In my opinion, the best technique is to reduce the line height on the table rows (tr) instead of on the ::first-line. As previously discussed, you’ll be 1 px short of the grid per line of line-wrapped text. This is the best compromise that’s possible given the current limitation of the CSS Table Model. Here’s the final stylesheet:

table
{
  border-collapse: collapse;
--border-thickness: 1px;

--text-height: 1.5em;
  line-height: var(--text-height);

--vertical-margin: var(--text-height);
  margin-top: calc(
    var(--vertical-margin) - var(--border-thickness)
  );
  margin-bottom: var(--vertical-margin);
}

table tr
{
  line-height: calc(
    var(--text-height) - var(--border-thickness)
  );
}

table th,
table td
{
  border: var(--border-thickness) solid black;
}

This is your best option and as good as it gets without involving JavaScript. Anything is possible with JavaScript, such as appending a block pseudo-element into each cell to account for the pixels lost to the reduced line-height inside each cell. Not to suggest you do that! It would be bad for page performance and trigger reflows and layout recalculations per row in your table.

If you insist on pixel-perfect results, you can get there by wrapping all content inside your tables in inline-block elements. With this setup, you can reduce the box’s height by 1 px and hide its overflow. There’s nothing inherently wrong with this approach, but it requires you to change the markup of all your tables to introduce the extra elements. That’s either a feasible option or a lot of extra work, depending on how your tables are created.

You can get to the desired result, but it’s probably not worth the implementation overhead. Alternatively, you could always consider removing the table borders entirely. That would resolve the root cause of the problem, but also compromise your table’s legibility.

Whatever you choose, make sure that your font-sizes, line-heights, and border-widths use integer values. Otherwise, rounding errors can accumulate over a few dozen lines of text and ruin your baseline grid. For whatever reason, the collapsed borders of the CSS Table layout seem to be especially prone to this effect. Browsers cope much more consistently and reliably with integer values than floats; even on devices that apply fractional display scaling.