Aligning list items vertically into columns
I was recently working on styling a table of contents for a web textbook. The overall structure is an ordered list of chapters. Each list item has the chapter number and name, which can be clicked to expand/collapse a nested ordered list of pages.
The markup looked like this:
See the Pen TOC Alignment, Initial by Ian Kim (@iansjk-the-decoder) on CodePen.
Without any additional styling, there were some alignment issues:
- page names aren‘t vertically aligned (e.g. page 1.10 and 1.11 have page names further to the right than the previous pages; same with pages in chapter 12)
- when page names wrap onto another line, they start from the leftmost edge (in the area that a page number would be)
My objective was to align page numbers and page names vertically in two columns while maintaining the original HTML structure, which turned out to be a bit trickier than I initially thought.
A naïve approach
One way to vertically align the page numbers and names is to set a fixed pixel width for the page number, and then assign any remaining space to the page name:
ol. > li > a {
display: grid;
grid-template-columns: <some-fixed-value>px 1fr;
}
Determining how wide the page number should be
One thing I realized was that digits would likely have different widths. Surely “1” takes up less horizontal space than “5”?
I found this very practical Stack Overflow answer that compared digit widths by repeating each digit the same number of times and visually checking which digit string was the widest. So I opened a tab to about:blank
, set the font to Helvetica, then printed each digit 30 times:
[...Array(10).keys()].map((i) => {
const p = document.createElement("p");
p.innerText = `${i}`.repeat(30);
document.body.appendChild(p);
});
I tried it in a new blank page with Helvetica as the font:
OK, so it looks like “1” takes up less space than the rest of the digits, but all the other digits take up the same amount of horizontal space.
I double checked and realized that our theme was using Helvetica Neue, not Helvetica, so I tried again with that font family:
Turns out Helvetica Neue has equal digit spacing by default, including for “1”!
In published content, the longest page number we had was something like “12.12”. Allocating enough space for that (plus a bit extra) worked out to be about 45 pixels. (Note: we could have also used a relative unit like 5ch
instead.)
Problems with this approach
Because the page number has the same width in every case, we have to set it to the maximum width that the page number would take up (about 45px, as mentioned above). But if we go back to chapter 1, we see there‘s considerably more space to the right of “1.1” compared to a longer page number like “12.13”:
See the Pen TOC Alignment, Fixed Width by Ian Kim (@iansjk-the-decoder) on CodePen.
Ideally, we’d vertically align the page numbers and names while providing just enough space between them.
An alternative approach using CSS tables
If we jumped into a time machine to the year 1998, we might have accomplished this using layout tables (🤢):
<ol>
<button>
<span class="chapter-number">1</span>
<span class="chapter-name">Title of chapter 1</span>
</button>
<table>
<tr>
<td class="page-number">1.1</td>
<td class="page-name">Foo</td>
</tr>
<tr>
<td class="page-number">1.2</td>
<td class="page-name">Bar</td>
</tr>
</table>
</ol>
Which produces this result:
See the Pen TOC Alignment, Layout Tables by Ian Kim (@iansjk-the-decoder) on CodePen.
Now the pages in chapter 12 have enough space between the page number and name without adding too much space to pages in chapter 1. There are some obvious problems with this approach though:
- the table of contents is semantically a list, not a table
<tr>
elements aren‘t allowed to have non-<td>
/<th>
children, so we can‘t wrap the contents of a row in a single<a>
element; we would have to add one for each<td>
Thankfully using CSS we can achieve the same visual result while something close to this by using display: table
, display: table-row
, and display: table-cell
:
See the Pen TOC Alignment, CSS Tables (No <a>) by Ian Kim (@iansjk-the-decoder) on CodePen.
The problem of intervening elements
You might notice that the DOM structure in the above example of CSS tables is different from what was originally presented. Notably, the intervening <a>
tags are missing from the page listitems. (Which makes for a poor table of contents–what’s the point if it doesn’t link to anything?)
What happens if we make the DOM look the same as the original example?
See the Pen TOC Alignment, CSS Tables (No <a>) by Ian Kim (@iansjk-the-decoder) on CodePen.
Now we’ve interrupted the flow from table → table-row → table-cell with the intervening <a>
tag. As a result, the tabular layout is broken, giving us the same result as what we started with.
Combining CSS tables with display: contents
What if we could maintain a particular DOM structure (for semantic/accessibility reasons) but present a different DOM structure to CSS? We can do that by marking some nodes as “skipped in CSS” with display: contents
:
One answer to this is display: contents;—a magical new display value that essentially makes the container disappear, making the child elements children of the element the next level up in the DOM.
We can revisit the example above that uses the original DOM structure and style the intervening nodes between the display: table-row
and display: table-cell
with display: contents
, which restores the direct parent-child relationship between table-row
and table-cell
:
See the Pen TOC Alignment, CSS Tables by Ian Kim (@iansjk-the-decoder) on CodePen.
Now we have vertically aligned the page numbers and page names in two columns while maintaining the correct semantic DOM structure. In addition, with this approach we’re not allocating a fixed amount of space, so if the longest page number is “1.1” instead of “111.111.111”, there won’t be a ton of extra space between the page number and page name.
Important things to note
Notice that when you select a DOM node in the devtools inspector that’s styled with display: contents
, nothing is highlighted on the page. In addition, adding padding or margin to nodes styled with display: contents
has no effect: