2 title: Adventure in Responsive WWW Tables
3 copyright: 2020 Nick Bowler
5 published: 2020-07-05T23:25:02-0400
8 *[CSS]: Cascading Style Sheets
9 *[HTML]: HyperText Markup Language
11 A quest to improve table display on the World Wide Web, using modern
12 technologies that degrade gracefully.
14 Sometimes, when presenting tables, better use of screen (or paper) space is
15 achieved by displaying table rows in two (or more) side-by-side vertical
16 columns. I would generally use this approach whenever rows can be reasonably
17 formatted to fit in less than half the page width and the table is sufficiently
20 For an example, compare and contrast [table 1](#t1) (a straightforward HTML
21 table) with [table 2](#t2) (exactly the same information, split into two
36 {:#t1 caption="<%= counter(:table) %>:
37 An inefficient use of available space"}
39 ID | Name | Age | ID | Name | Age
40 ---|-----------|-----|----|----------|----
41 1 | Barbara | 34 | 6 | Jennifer | 38
42 2 | Charles | 42 | 7 | Jessica | 37
43 3 | David | 53 | 8 | John | 31
44 4 | Elizabeth | 32 | 9 | Joseph | 38
45 5 | James | 33 | 10 | Karen | 57
46 {:#t2 caption="<%= counter(:table) %>:
47 Same information, more compact presentation"}
49 This works, but there is a significant problem: the presentation of [table
50 2](#t2) into two sets of columns is hardcoded in the HTML markup. Aside from a
51 general aversion to mixing content with style, there is a practical problem: on
52 a narrow display the table could easily be too wide. If columns end up off the
53 screen this would be worse than [table 1](#t1) which probably fits nicely.
55 What we really want is for the user agent to automatically select the most
56 appropriate presentation for display---a so-called responsive layout.
60 [cssgrid]: //www.w3.org/TR/css-grid-1/
62 The [CSS grid module][cssgrid] enables CSS rules to define a table-like
63 presentation, with quite a lot of flexibility. Unfortunately CSS grids
64 have a number of major practical problems and limitations. We will discuss
65 and overcome some of these limitations to achieve a responsive layout for
66 our table with a high degree of compatibility.
68 The first and most obvious problem is that not all browsers support CSS grids.
69 Whatever solution we implement must, to the greatest extent possible, work in
70 all browsers. This means graceful degradation: if the grid does not work for
71 any reason we want to get a readable table, ideally one like [table 1](#t1).
73 Even with the latest and greatest browser, there is no guarantee the styles
74 actually work because they might not even load. Firefox still lets users
75 disable styles at the touch of a button. Our table must be readable in
78 With a CSS grid, the _grid items_ are all the child elements of the _grid
79 container_, which is an element styled with `display: grid`. In particular,
80 descendent elements that are not direct children of the grid container do not
81 participate in the grid layout. While the CSS `display: contents` style exists
82 to help overcome this limitation, it turns out to not be very useful: among
83 browsers that support grids, not all support `display: contents`. Thus we
84 cannot expect grid layouts to work if we depend on `display: contents`.
86 If you search online for laying out tabular data with CSS grid, you will find
87 many examples that flatten all the table cells into a linear sequence of
88 elements within a big container `div` or similar. This satisfies the markup
89 constraints but is, quite frankly, terrible. The structure of the data has
90 been moved from the markup into the stylesheet. The table is meaningless if
91 the stylesheet is not working for any reason. Whatever happened to separation
94 To achieve graceful degradation, we must start with markup that looks very
95 similar to the markup of [table 1](#t1)---a straightforward HTML table, and
96 only apply styles on top of that.
100 The markup constraints introduce an immediate practical problem: the actual
101 table cells cannot be the primary grid items for our responsive layout,
102 because we need to arrange items row-by-row. So instead, our table rows will
103 be the grid items. Therefore the grid containers must be `thead` and `tbody`.
104 We will not bother with `tfoot` at this time, but it should be pretty similar
107 Using a media query, a simple 2-column grid can be configured which fills the
108 available space only if there is sufficient width available.
110 #### <%= counter(:listing) %>: Markup for [table 3](#t3)
111 <generate-xhtml-listing target='t3' />
113 #### <%= counter(:listing) %>: Style for [table 3](#t3)
114 <style type='text/css' generate-listing>
115 @supports (display: grid) {
117 grid-column-gap: 0.5ex;
118 grid-template-columns: 1fr 1fr;
121 @media (min-width: 35em) {
122 #t3>thead, #t3>tbody { display: grid; }
128 <caption><%= counter(:table) %>: First layout attempt with grid</caption>
130 <tr><th>Header</th></tr>
133 <tr><td>Row 1</td></tr>
134 <tr><td>Row 2</td></tr>
135 <tr><td>Row 3</td></tr>
136 <tr><td>Row 4</td></tr>
137 <tr><td>Row 5</td></tr>
138 <tr><td>Row 6</td></tr>
142 [Table 3](#t3) shows the basic structure working but there are several
143 obvious deficiencies. The most glaring is the lack of a header on the
144 right-hand grid column. Some of the table styling (odd/even row shading
145 in particular) is busted. And the rows are not in the desired location:
146 row 2 should be beneath row 1, not beside it.
148 ## Duplicating the header
150 There is unfortunately no way to duplicate the header in a stylesheet, so we
151 have no choice but to duplicate the header row in the table markup itself. We
152 can hide it with an inline style attribute and reveal it when the grid is
153 enabled in the media query. An inline `display: none` is widely supported to
154 hide this row normally.
156 #### <%= counter(:listing) %>: Markup for [table 4](#t4)
157 <generate-xhtml-listing target='t4' />
159 #### <%= counter(:listing) %>: Style for [table 4](#t4)
160 <style type='text/css' generate-listing>
161 @supports (display: grid) {
163 grid-column-gap: 0.5ex;
164 grid-template-columns: 1fr 1fr;
167 @media (min-width: 35em) {
168 #t4>*>tr { display: initial !important; }
169 #t4>thead, #t4>tbody { display: grid; }
175 <caption><%= counter(:table) %>: Duplicated header on grid</caption>
177 <tr><th>Header</th></tr>
178 <tr style='display: none;'><th>Header (duplicated)</th></tr>
181 <tr><td>Row 1</td></tr>
182 <tr><td>Row 2</td></tr>
183 <tr><td>Row 3</td></tr>
184 <tr><td>Row 4</td></tr>
185 <tr><td>Row 5</td></tr>
186 <tr><td>Row 6</td></tr>
190 This is not perfect: the duplicate header will appear if styles are disabled
191 or if the browser does not support CSS at all, which includes all text-mode
192 browsers that I am aware of. But it is a fairly minor annoyance and the
193 situation can be improved somewhat with some hacks that we might explore in
196 ## Fixing the row placement
198 Our grid currently consists of two grid columns and a dynamic number of
199 grid rows, and the automatic grid layout fills each row before creating
200 a new one. We can place the rows where they need to go by styling the
201 first half of the rows differently from the last half. The first half
202 can be forced to the first column, and the remainder will auto-place
203 to the correct locations when using `grid-auto-flow: dense`.
205 Perhaps the easiest way to do this is to markup the halfway point with a
206 class, so that CSS selectors can reference this row without hardcoding the
207 number of rows in the table.
209 #### <%= counter(:listing) %>: Markup for [table 5](#t5)
210 <generate-xhtml-listing target='t5' />
212 #### <%= counter(:listing) %>: Style for [table 5](#t5)
213 <style type='text/css' generate-listing>
214 @supports (display: grid) {
216 grid-column-gap: 0.5ex;
217 grid-template-columns: 1fr 1fr;
218 grid-auto-flow: dense;
221 #t5>tbody>tr { grid-column-start: 1; }
222 #t5>tbody>tr.t5-split ~ tr { grid-column-start: auto; }
224 @media (min-width: 35em) {
225 #t5>*>tr { display: initial !important; }
226 #t5>thead, #t5>tbody { display: grid; }
232 <caption><%= counter(:table) %>: Correct row placement on grid</caption>
234 <tr><th>Header</th></tr>
235 <tr style='display: none;'><th>Header (duplicated)</th></tr>
238 <tr><td>Row 1</td></tr>
239 <tr><td>Row 2</td></tr>
240 <tr class='t5-split'><td>Row 3</td></tr>
241 <tr><td>Row 4</td></tr>
242 <tr><td>Row 5</td></tr>
243 <tr><td>Row 6</td></tr>
247 ## Fixing the row styling
249 Depending on the style used, [table 5](#t5) might be good enough. But here,
250 the odd/even row shading is messed up because the split was not performed
251 on an even-numbered row, and there is no bottom border on the first grid
252 column. Now that the the row positioning is correct, both of these issues
253 are pretty easy to fix in the stylesheet.
255 #### <%= counter(:listing) %>: Markup for [table 6](#t6)
256 <generate-xhtml-listing target='t6' />
258 #### <%= counter(:listing) %>: Style for [table 6](#t6)
259 <style type='text/css' generate-listing>
260 @supports (display: grid) {
262 grid-column-gap: 0.5ex;
263 grid-template-columns: 1fr 1fr;
264 grid-auto-flow: dense;
267 #t6>tbody>tr { grid-column-start: 1; }
268 #t6>tbody>tr.t6-split ~ tr { grid-column-start: auto; }
270 @media (min-width: 35em) {
271 #t6>*>tr { display: initial !important; }
272 #t6>thead, #t6>tbody { display: grid; }
274 #t6>tbody>tr.t6-split { border-bottom: 1px solid <%=
275 scss_get_colour(:ruledefault) %>; }
276 #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
277 background-color: initial;
279 #t6>tbody>tr.t6-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
280 background-color: <%= scss_get_colour(:tableshade) %>;
287 <caption><%= counter(:table) %>: Correct row styling on grid</caption>
289 <tr><th>Header</th></tr>
290 <tr style='display: none;'><th>Header (duplicated)</th></tr>
293 <tr><td>Row 1</td></tr>
294 <tr><td>Row 2</td></tr>
295 <tr class='t6-split'><td>Row 3</td></tr>
296 <tr><td>Row 4</td></tr>
297 <tr><td>Row 5</td></tr>
298 <tr><td>Row 6</td></tr>
304 [Table 6](#t6) is pretty close to what we want, except for the minor detail
305 that real tables typically have more than one column. So let's try applying
306 these techniques to [table 1](#t1).
308 #### <%= counter(:listing) %>: Markup for [table 7](#t7)
309 <generate-xhtml-listing target='t7' />
311 #### <%= counter(:listing) %>: Style for [table 7](#t7)
312 <style type='text/css' generate-listing>
313 @supports (display: grid) {
315 grid-column-gap: 0.5ex;
316 grid-template-columns: 1fr 1fr;
317 grid-auto-flow: dense;
320 #t7>tbody>tr { grid-column-start: 1; }
321 #t7>tbody>tr.t7-split ~ tr { grid-column-start: auto; }
323 @media (min-width: 35em) {
324 #t7>*>tr { display: initial !important; }
325 #t7>thead, #t7>tbody { display: grid; }
327 #t7>tbody>tr.t7-split { border-bottom: 1px solid <%=
328 scss_get_colour(:ruledefault) %>; }
329 #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
330 background-color: initial;
332 #t7>tbody>tr.t7-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
333 background-color: <%= scss_get_colour(:tableshade) %>;
340 <caption><%= counter(:table) %>: Column misalignment on grid</caption>
342 <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
343 <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
346 <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
347 <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
348 <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
349 <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
350 <tr class='t7-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
351 <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
352 <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
353 <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
354 <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
355 <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
359 The fallback for [table 7](#t7) works properly but when the grid columns are
360 activated, the alignment of table cells into their respective columns is not
361 correct. This is something that we could potentially solve with CSS subgrids,
362 but as with `display: contents` lack of browser support makes their use
365 Nevertheless, provided that the width of each cell is independent of its
366 contents, they will all line up correctly. There are many ways to do this
367 in a stylesheet. We will do it with more grids.
369 #### <%= counter(:listing) %>: Markup for [table 8](#t8)
370 <generate-xhtml-listing target='t8' />
372 #### <%= counter(:listing) %>: Style for [table 8](#t8)
373 <style type='text/css' generate-listing>
374 @supports (display: grid) {
376 grid-column-gap: 0.5ex;
377 grid-template-columns: 1fr 1fr;
378 grid-auto-flow: dense;
381 #t8>tbody>tr { grid-column-start: 1; }
382 #t8>tbody>tr.t8-split ~ tr { grid-column-start: auto; }
384 @media (min-width: 35em) {
385 #t8>*>tr { display: grid !important; }
386 #t8>thead, #t8>tbody { display: grid; }
388 #t8>tbody>tr.t8-split { border-bottom: 1px solid <%=
389 scss_get_colour(:ruledefault) %>; }
390 #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(even) {
391 background-color: initial;
393 #t8>tbody>tr.t8-split:nth-of-type(odd) ~ tr:nth-of-type(odd) {
394 background-color: <%= scss_get_colour(:tableshade) %>;
399 grid-template-columns: minmax(3em, 1fr) 5fr minmax(3em, 1fr);
405 <caption><%= counter(:table) %>: Fully working responsive table</caption>
407 <tr> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
408 <tr style='display: none;'> <th>ID</th> <th>Name</th> <th>Age</th> </tr>
411 <tr> <td>1</td> <td>Barbara</td> <td>34</td> </tr>
412 <tr> <td>2</td> <td>Charles</td> <td>42</td> </tr>
413 <tr> <td>3</td> <td>David</td> <td>53</td> </tr>
414 <tr> <td>4</td> <td>Elizabeth</td> <td>32</td> </tr>
415 <tr class='t8-split'> <td>5</td> <td>James</td> <td>33</td> </tr>
416 <tr> <td>6</td> <td>Jennifer</td> <td>38</td> </tr>
417 <tr> <td>7</td> <td>Jessica</td> <td>37</td> </tr>
418 <tr> <td>8</td> <td>John</td> <td>31</td> </tr>
419 <tr> <td>9</td> <td>Joseph</td> <td>38</td> </tr>
420 <tr> <td>10</td> <td>Karen</td> <td>57</td> </tr>
426 Some issues remain, but I think the results in [table 8](#t8) are pretty good.
428 I don't consider the loss of automatic cell sizing to be a big deal, sizes
429 usually need to be tweaked per-table anyway and the use of `fr` units can
430 give pretty nice results that scale with available width.
432 More than two grid columns should be possible but I have not attempted to do
433 so. There might be a lot more fighting with the automatic grid placement.
435 Depending on the table it may be useful to tweak various alignment properties
438 It seems that Firefox really sucks at printing these grids if they happen to
439 cross a page boundary (I did not try printing in other browsers). Firefox is
440 pretty bad at printing regular tables when they do this too, but it is way
441 worse with grids. Using e.g. `@media screen` in the stylesheet can help by
442 falling back to an ordinary table for printouts.