The Citrine Citadel
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).
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 |
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.
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.
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.
<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>
@supports (display: grid) {
#t3>* {
grid-column-gap: 0.5ex;
grid-template-columns: 1fr 1fr;
}
@media (min-width: 35em) {
#t3>thead, #t3>tbody { display: 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.
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.
<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>
@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; }
}
}
Header |
---|
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.
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.
<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>
@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; }
}
}
Header |
---|
Row 1 |
Row 2 |
Row 3 |
Row 4 |
Row 5 |
Row 6 |
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.
<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>
@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;
}
}
}
Header |
---|
Row 1 |
Row 2 |
Row 3 |
Row 4 |
Row 5 |
Row 6 |
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.
<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>
@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;
}
}
}
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.
<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>
@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);
}
}
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 |
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.