The Citrine Citadel


Adventure in Responsive WWW Tables

Posted 2020-07-05

A quest to improve table display on the World Wide Web, using modern technologies that degrade gracefully.

Sometimes, when presenting tables, better use of screen (or paper) space is achieved by displaying table rows in two (or more) side-by-side vertical columns. I would generally use this approach whenever rows can be reasonably formatted to fit in less than half the page width and the table is sufficiently long.

For an example, compare and contrast table 1 (a straightforward HTML table) with table 2 (exactly the same information, split into two sets of columns).

Table 1: An inefficient use of available space
ID Name Age
1 Barbara 34
2 Charles 42
3 David 53
4 Elizabeth 32
5 James 33
6 Jennifer 38
7 Jessica 37
8 John 31
9 Joseph 38
10 Karen 57
Table 2: Same information, more compact presentation
ID Name Age ID Name Age
1 Barbara 34 6 Jennifer 38
2 Charles 42 7 Jessica 37
3 David 53 8 John 31
4 Elizabeth 32 9 Joseph 38
5 James 33 10 Karen 57

This works, but there is a significant problem: the presentation of table 2 into two sets of columns is hardcoded in the HTML markup. Aside from a general aversion to mixing content with style, there is a practical problem: on a narrow display the table could easily be too wide. If columns end up off the screen this would be worse than table 1 which probably fits nicely.

What we really want is for the user agent to automatically select the most appropriate presentation for display—a so-called responsive layout.

Enter CSS Grid

The CSS grid module enables CSS rules to define a table-like presentation, with quite a lot of flexibility. Unfortunately CSS grids have a number of major practical problems and limitations. We will discuss and overcome some of these limitations to achieve a responsive layout for our table with a high degree of compatibility.

The first and most obvious problem is that not all browsers support CSS grids. Whatever solution we implement must, to the greatest extent possible, work in all browsers. This means graceful degradation: if the grid does not work for any reason we want to get a readable table, ideally one like table 1.

Even with the latest and greatest browser, there is no guarantee the styles actually work because they might not even load. Firefox still lets users disable styles at the touch of a button. Our table must be readable in these scenarios.

With a CSS grid, the grid items are all the child elements of the grid container, which is an element styled with display: grid. In particular, descendent elements that are not direct children of the grid container do not participate in the grid layout. While the CSS display: contents style exists to help overcome this limitation, it turns out to not be very useful: among browsers that support grids, not all support display: contents. Thus we cannot expect grid layouts to work if we depend on display: contents.

If you search online for laying out tabular data with CSS grid, you will find many examples that flatten all the table cells into a linear sequence of elements within a big container div or similar. This satisfies the markup constraints but is, quite frankly, terrible. The structure of the data has been moved from the markup into the stylesheet. The table is meaningless if the stylesheet is not working for any reason. Whatever happened to separation of content and style?

To achieve graceful degradation, we must start with markup that looks very similar to the markup of table 1—a straightforward HTML table, and only apply styles on top of that.

Rows as grid items

The markup constraints introduce an immediate practical problem: the actual table cells cannot be the primary grid items for our responsive layout, because we need to arrange items row-by-row. So instead, our table rows will be the grid items. Therefore the grid containers must be thead and tbody. We will not bother with tfoot at this time, but it should be pretty similar to thead.

Using a media query, a simple 2-column grid can be configured which fills the available space only if there is sufficient width available.

Listing 1: Markup for table 3
<table id='t3'>
  <caption>Table 3: First layout attempt with grid</caption>
  <thead>
    <tr><th>Header</th></tr>
  </thead>
  <tbody>
    <tr><td>Row 1</td></tr>
    <tr><td>Row 2</td></tr>
    <tr><td>Row 3</td></tr>
    <tr><td>Row 4</td></tr>
    <tr><td>Row 5</td></tr>
    <tr><td>Row 6</td></tr>
  </tbody>
</table>
Listing 2: Style for table 3
@supports (display: grid) {
  #t3>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
  }

  @media (min-width: 35em) {
    #t3>thead, #t3>tbody { display: grid; }
  }
}
Table 3: First layout attempt with grid
Header
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6

Table 3 shows the basic structure working but there are several obvious deficiencies. The most glaring is the lack of a header on the right-hand grid column. Some of the table styling (odd/even row shading in particular) is busted. And the rows are not in the desired location: row 2 should be beneath row 1, not beside it.

Duplicating the header

There is unfortunately no way to duplicate the header in a stylesheet, so we have no choice but to duplicate the header row in the table markup itself. We can hide it with an inline style attribute and reveal it when the grid is enabled in the media query. An inline display: none is widely supported to hide this row normally.

Listing 3: Markup for table 4
<table id='t4'>
  <caption>Table 4: Duplicated header on grid</caption>
  <thead>
    <tr><th>Header</th></tr>
    <tr style='display: none;'><th>Header (duplicated)</th></tr>
  </thead>
  <tbody>
    <tr><td>Row 1</td></tr>
    <tr><td>Row 2</td></tr>
    <tr><td>Row 3</td></tr>
    <tr><td>Row 4</td></tr>
    <tr><td>Row 5</td></tr>
    <tr><td>Row 6</td></tr>
  </tbody>
</table>
Listing 4: Style for table 4
@supports (display: grid) {
  #t4>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
  }

  @media (min-width: 35em) {
    #t4>*>tr { display: initial !important; }
    #t4>thead, #t4>tbody { display: grid; }
  }
}
Table 4: Duplicated header on grid
Header
Header (duplicated)
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6

This is not perfect: the duplicate header will appear if styles are disabled or if the browser does not support CSS at all, which includes all text-mode browsers that I am aware of. But it is a fairly minor annoyance and the situation can be improved somewhat with some hacks that we might explore in another adventure.

Fixing the row placement

Our grid currently consists of two grid columns and a dynamic number of grid rows, and the automatic grid layout fills each row before creating a new one. We can place the rows where they need to go by styling the first half of the rows differently from the last half. The first half can be forced to the first column, and the remainder will auto-place to the correct locations when using grid-auto-flow: dense.

Perhaps the easiest way to do this is to markup the halfway point with a class, so that CSS selectors can reference this row without hardcoding the number of rows in the table.

Listing 5: Markup for table 5
<table id='t5'>
  <caption>Table 5: Correct row placement on grid</caption>
  <thead>
    <tr><th>Header</th></tr>
    <tr style='display: none;'><th>Header (duplicated)</th></tr>
  </thead>
  <tbody>
    <tr><td>Row 1</td></tr>
    <tr><td>Row 2</td></tr>
    <tr class='t5-split'><td>Row 3</td></tr>
    <tr><td>Row 4</td></tr>
    <tr><td>Row 5</td></tr>
    <tr><td>Row 6</td></tr>
  </tbody>
</table>
Listing 6: Style for table 5
@supports (display: grid) {
  #t5>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
    grid-auto-flow: dense;
  }

  #t5>tbody>tr { grid-column-start: 1; }
  #t5>tbody>tr.t5-split ~ tr { grid-column-start: auto; }

  @media (min-width: 35em) {
    #t5>*>tr { display: initial !important; }
    #t5>thead, #t5>tbody { display: grid; }
  }
}
Table 5: Correct row placement on grid
Header
Header (duplicated)
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6

Fixing the row styling

Depending on the style used, table 5 might be good enough. But here, the odd/even row shading is messed up because the split was not performed on an even-numbered row, and there is no bottom border on the first grid column. Now that the the row positioning is correct, both of these issues are pretty easy to fix in the stylesheet.

Listing 7: Markup for table 6
<table id='t6'>
  <caption>Table 6: Correct row styling on grid</caption>
  <thead>
    <tr><th>Header</th></tr>
    <tr style='display: none;'><th>Header (duplicated)</th></tr>
  </thead>
  <tbody>
    <tr><td>Row 1</td></tr>
    <tr><td>Row 2</td></tr>
    <tr class='t6-split'><td>Row 3</td></tr>
    <tr><td>Row 4</td></tr>
    <tr><td>Row 5</td></tr>
    <tr><td>Row 6</td></tr>
  </tbody>
</table>
Listing 8: Style for table 6
@supports (display: grid) {
  #t6>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
    grid-auto-flow: dense;
  }

  #t6>tbody>tr { grid-column-start: 1; }
  #t6>tbody>tr.t6-split ~ tr { grid-column-start: auto; }

  @media (min-width: 35em) {
    #t6>*>tr { display: initial !important; }
    #t6>thead, #t6>tbody { display: grid; }

    #t6>tbody>tr.t6-split { border-bottom: 1px solid #d3d3d3; }
    #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
      background-color: initial;
    }
    #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
      background-color: #f5f5f5;
    }
  }
}
Table 6: Correct row styling on grid
Header
Header (duplicated)
Row 1
Row 2
Row 3
Row 4
Row 5
Row 6

Realistic Tables

Table 6 is pretty close to what we want, except for the minor detail that real tables typically have more than one column. So let’s try applying these techniques to table 1.

Listing 9: Markup for table 7
<table id='t7'>
  <caption>Table 7: Column misalignment on grid</caption>
  <thead>
    <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
    <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
  </thead>
  <tbody>
    <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
    <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
    <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
    <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
    <tr class='t7-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
    <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
    <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
    <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
    <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
    <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
  </tbody>
</table>
Listing 10: Style for table 7
@supports (display: grid) {
  #t7>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
    grid-auto-flow: dense;
  }

  #t7>tbody>tr { grid-column-start: 1; }
  #t7>tbody>tr.t7-split ~ tr { grid-column-start: auto; }

  @media (min-width: 35em) {
    #t7>*>tr { display: initial !important; }
    #t7>thead, #t7>tbody { display: grid; }

    #t7>tbody>tr.t7-split { border-bottom: 1px solid #d3d3d3; }
    #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
      background-color: initial;
    }
    #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
      background-color: #f5f5f5;
    }
  }
}
Table 7: Column misalignment on grid
ID Name Age
ID Name Age
1 Barbara 34
2 Charles 42
3 David 53
4 Elizabeth 32
5 James 33
6 Jennifer 38
7 Jessica 37
8 John 31
9 Joseph 38
10 Karen 57

The fallback for table 7 works properly but when the grid columns are activated, the alignment of table cells into their respective columns is not correct. This is something that we could potentially solve with CSS subgrids, but as with display: contents lack of browser support makes their use untenable.

Nevertheless, provided that the width of each cell is independent of its contents, they will all line up correctly. There are many ways to do this in a stylesheet. We will do it with more grids.

Listing 11: Markup for table 8
<table id='t8'>
  <caption>Table 8: Fully working responsive table</caption>
  <thead>
    <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
    <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
  </thead>
  <tbody>
    <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
    <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
    <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
    <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
    <tr class='t8-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
    <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
    <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
    <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
    <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
    <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
  </tbody>
</table>
Listing 12: Style for table 8
@supports (display: grid) {
  #t8>* {
    grid-column-gap: 0.5ex;
    grid-template-columns: 1fr 1fr;
    grid-auto-flow: dense;
  }

  #t8>tbody>tr { grid-column-start: 1; }
  #t8>tbody>tr.t8-split ~ tr { grid-column-start: auto; }

  @media (min-width: 35em) {
    #t8>*>tr { display: grid !important; }
    #t8>thead, #t8>tbody { display: grid; }

    #t8>tbody>tr.t8-split { border-bottom: 1px solid #d3d3d3; }
    #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
      background-color: initial;
    }
    #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
      background-color: #f5f5f5;
    }
  }

  #t8>*>tr {
    grid-template-columns: minmax(3em, 1fr) 5fr minmax(3em, 1fr);
  }
}
Table 8: Fully working responsive table
ID Name Age
ID Name Age
1 Barbara 34
2 Charles 42
3 David 53
4 Elizabeth 32
5 James 33
6 Jennifer 38
7 Jessica 37
8 John 31
9 Joseph 38
10 Karen 57

Epilogue

Some issues remain, but I think the results in table 8 are pretty good.

I don’t consider the loss of automatic cell sizing to be a big deal, sizes usually need to be tweaked per-table anyway and the use of fr units can give pretty nice results that scale with available width.

More than two grid columns should be possible but I have not attempted to do so. There might be a lot more fighting with the automatic grid placement.

Depending on the table it may be useful to tweak various alignment properties of the grid items.

It seems that Firefox really sucks at printing these grids if they happen to cross a page boundary (I did not try printing in other browsers). Firefox is pretty bad at printing regular tables when they do this too, but it is way worse with grids. Using e.g. @media screen in the stylesheet can help by falling back to an ordinary table for printouts.