+---
+title: Adventure in Responsive WWW Tables
+copyright: 2020 Nick Bowler
+license: cc-by-sa-4.0
+published: 2020-07-05T23:25:02-0400
+---
+
+*[CSS]: Cascading Style Sheets
+*[HTML]: HyperText Markup Language
+
+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](#t1) (a straightforward HTML
+table) with [table 2](#t2) (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
+ {:#t1 caption="<%= counter(:table) %>:
+ An inefficient use of available space"}
+
+ 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
+ {:#t2 caption="<%= counter(:table) %>:
+ Same information, more compact presentation"}
+
+This works, but there is a significant problem: the presentation of [table
+2](#t2) 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](#t1) 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
+
+[cssgrid]: //www.w3.org/TR/css-grid-1/
+
+The [CSS grid module][cssgrid] 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](#t1).
+
+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](#t1)---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.
+
+#### <%= counter(:listing) %>: Markup for [table 3](#t3)
+<generate-xhtml-listing target='t3' />
+
+#### <%= counter(:listing) %>: Style for [table 3](#t3)
+<style type='text/css' generate-listing>
+@supports (display: grid) {
+ #t3>* {
+ grid-column-gap: 0.5ex;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ @media (min-width: 35em) {
+ #t3>thead, #t3>tbody { display: grid; }
+ }
+}
+</style>
+
+<table id='t3'>
+ <caption><%= counter(:table) %>: 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>
+
+[Table 3](#t3) 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.
+
+#### <%= counter(:listing) %>: Markup for [table 4](#t4)
+<generate-xhtml-listing target='t4' />
+
+#### <%= counter(:listing) %>: Style for [table 4](#t4)
+<style type='text/css' generate-listing>
+@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; }
+ }
+}
+</style>
+
+<table id='t4'>
+ <caption><%= counter(:table) %>: 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>
+
+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.
+
+#### <%= counter(:listing) %>: Markup for [table 5](#t5)
+<generate-xhtml-listing target='t5' />
+
+#### <%= counter(:listing) %>: Style for [table 5](#t5)
+<style type='text/css' generate-listing>
+@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; }
+ }
+}
+</style>
+
+<table id='t5'>
+ <caption><%= counter(:table) %>: 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>
+
+## Fixing the row styling
+
+Depending on the style used, [table 5](#t5) 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.
+
+#### <%= counter(:listing) %>: Markup for [table 6](#t6)
+<generate-xhtml-listing target='t6' />
+
+#### <%= counter(:listing) %>: Style for [table 6](#t6)
+<style type='text/css' generate-listing>
+@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 <%=
+ scss_get_var(:ruledefaultcolour) %>; }
+ #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: <%= scss_get_var(:tableshadecolour) %>;
+ }
+ }
+}
+</style>
+
+<table id='t6'>
+ <caption><%= counter(:table) %>: 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>
+
+# Realistic Tables
+
+[Table 6](#t6) 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](#t1).
+
+#### <%= counter(:listing) %>: Markup for [table 7](#t7)
+<generate-xhtml-listing target='t7' />
+
+#### <%= counter(:listing) %>: Style for [table 7](#t7)
+<style type='text/css' generate-listing>
+@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 <%=
+ scss_get_var(:ruledefaultcolour) %>; }
+ #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: <%= scss_get_var(:tableshadecolour) %>;
+ }
+ }
+}
+</style>
+
+<table id="t7">
+ <caption><%= counter(:table) %>: 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>
+
+The fallback for [table 7](#t7) 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.
+
+#### <%= counter(:listing) %>: Markup for [table 8](#t8)
+<generate-xhtml-listing target='t8' />
+
+#### <%= counter(:listing) %>: Style for [table 8](#t8)
+<style type='text/css' generate-listing>
+@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 <%=
+ scss_get_var(:ruledefaultcolour) %>; }
+ #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: <%= scss_get_var(:tableshadecolour) %>;
+ }
+ }
+
+ #t8>*>tr {
+ grid-template-columns: minmax(3em, 1fr) 5fr minmax(3em, 1fr);
+ }
+}
+</style>
+
+<table id="t8">
+ <caption><%= counter(:table) %>: 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>
+
+# Epilogue
+
+Some issues remain, but I think the results in [table 8](#t8) 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.