Mads Stoumann
Posted on January 28, 2024
I've recently noticed a small paradox: Many years ago – before CSS grid — we used <table>
s to simulate grid layouts. Now that we have grid layouts, we use them to simulate tables! Which is wrong. Tables are for tabular data; and it doesn't make sense to present tabular data in a bunch of <div>
s.
The reason for this malpractice might be because tables can be a bit tricky to style, and that most CSS frameworks use border-collapse: collapse
for default table styling. As we'll see in this tutorial, collapsed borders are not always useful for table styling.
Let's look into the elements of a <table>
, and then how to structure and style them.
Elements
Besides the <table>
-element itself, you only need these 3 tags to do a basic table:
Tag | Description |
---|---|
td |
Table Data Cell |
th |
Table Header Cell |
tr |
Table Row |
Example:
<table>
<tr><th>Header</th></tr>
<tr><td>Content</td></tr>
</table>
However, to structure the table better, we can encapsulate the rows in:
Tag | Description |
---|---|
thead |
Table Header |
tbody |
Table Body |
tfoot |
Table Footer |
Finally, we can add a <caption>
to the table, and define columns in <col>
-tags within a <colgroup>
.
Example:
<table>
<caption>Super Heroes</caption>
<colgroup><col><col><col><col></colgroup>
<thead>
<tr><th>First Name</th><th>Last Name</th><th>Known As</th><th>Place</th></tr>
</thead>
<tbody>
<tr><td>Bruce</td><td>Wayne</td><td>Batman</td><td>Gotham City</td></tr>
<tr><td>Clark</td><td>Kent</td><td>Superman</td><td>Metropolis</td></tr>
<tr><td>Tony</td><td>Stark</td><td>Iron Man</td><td>Malibu</td></tr>
<tr><td>Peter</td><td>Parker</td><td>Spider-Man</td><td>New York City</td></tr>
<tr><td>Matt</td><td>Murdock</td><td>Daredevil</td><td>New York City</td></tr>
</tbody>
</table>
Without any styles, your browser will render this:
The default user-agent-styles are:
table {
border-collapse: separate;
text-indent: initial;
border-spacing: 2px;
}
Now, if we add a super-simple rule:
:is(td,th) {
border-style: solid;
}
We get:
Notice the separate borders. It doesn't look too nice ...
So, just to understand the popularity of collapsed borders (as well as a better font!), if we simply add:
table {
border-collapse: collapse;
font-family: system-ui;
}
... we get:
If we then add padding: .5ch 1ch
to our :is(td,th)
-selector and margin-block: 1rlh
to <caption>
, we get:
To recap, all we need to get the above styling, is this:
table {
border-collapse: collapse;
font-family: system-ui;
& caption { margin-block: 1rlh; }
&:is(td, th) {
border-style: solid;
padding: .5ch 1ch;
}
}
To place the <caption>
below the table instead, use:
table {
caption-side: bottom;
}
Zebra Stripes
To add odd/even zebra-stripes for columns, we can simply style the <col>
-tag:
col:nth-of-type(even) { background: #F2F2F2; }
For rows, it's similar:
tr:nth-of-type(odd) { background: #F2F2F2; }
Rounded corners
Rounded corners are a bit tricky. You can't just add border-radius
to a <table>
, so we have to target the first and last cell of the first and last rows:
th {
&:first-of-type { border-start-start-radius: .5em }
&:last-of-type { border-start-end-radius: .5em }
}
tr {
&:last-of-type {
& td {
&:first-of-type { border-end-start-radius: .5em }
&:last-of-type { border-end-end-radius: .5em }
}
}
}
... but still, nothing happens! That's because:
If your table has collapsed borders, you can't add
border-radius
.
So we'll have to use separate borders, and just mimick collapsed borders:
table {
border-spacing: 0;
}
:is(td, th) {
border-block-width: 1px 0;
border-inline-width: 1px 0;
&:last-of-type { border-inline-end-width: 1px }
}
And now we have rounded corners:
Split Columns
Let's keep the separate columns, and use the border-spacing
-property to add a gap between columns:
table {
border-spacing: 2ch 0;
& :is(td, th) {
border-inline-width: 1px;
}
}
We can even add border-radius
:
This is still just a <table>
, but much more readable if used as a "comparison table".
Split Rows
For split rows, we just need to update the second part (the y-axis) of the border-spacing
-property:
table {
border-spacing: 0 2ch;
& :is(td, th) {
border-block-width: 1px;
}
}
Hover and Focus
With large tables, it's important to know exactly where you are. For that we need :hover
, and — if you're working with a keyboard-navigable table — :focus-visble
-styles.
In this example, hover-styles are applied to both <col>
, <tr>
and <td>
:
Hovering rows and cells is straightforward:
td:hover {
background: #666666;
}
tr:hover {
background: #E6E6E6;
}
Hovering a <col>
is a bit more complicated.
You can add a rule:
col:hover {
background: #E6E6E6;
}
... but it doesn't work. Weirdly, if you select a col-element in Dev Tools and enable :hover
for it, it works — but not IRL.
Instead, we need to capture the hovering of cells using :has
, and then style the <col>
-element:
table {
&:has(:is(td,th):nth-child(1):hover col:nth-child(1) {
background: #E6E6E6;
}
So, what's going on?
Let's break it down:
If our table has a <td>
or a <th>
which is the nth-child(1)
and it's currently hovered, then select the <col>
with the same nth-child
-selector, and set it's background
.
Phew! ... and you need to repeat this code for each column: nth-child(2)
, nth-child(3)
etc.
Outlines
To show outlines on hover is also straightforward, and the same for cells and rows. You need to deduct the width from the offset:
:is(td, th, tr):hover {
outline: 2px solid #666;
outline-offset: -2px;
}
Column Outlines
To outline a column is very tricky, but looks nice:
If the cells have a border-width
of 1px
, you can set the <col>
's border-width
to 2px
on hover, but then the whole table shifts.
Álvaro Montoro suggested using background-gradients on <col>
to simulate a border, which works fine, if the table cells are transparent.
To make it work with border-radius
and keeping whatever background the cells might have, I ended up using a pseudo-element per cell:
:is(td,th) {
position: relative;
&::after {
border-inline: 2px solid transparent;
border-radius: inherit;
content: '';
inset: -2px 0 0 0;
position: absolute;
}
}
tr:first-of-type th::after {
border-block-start: 2px solid transparent;
}
tr:last-of-type td::after {
border-block-end: 2px solid transparent;
}
... and then, similar to what we did with col-hover, targetting all cells with the same "col-index" on hover:
:has(:is(td,th):nth-child(1):hover :is(td,th):nth-child(1) {
border-color: #666;
}
Repeat for all columns.
Aligning text
In an old specification, you could add an align
-property to the <col>
-element. That doesn't work anymore.
Example: You want to center the text in the second column and right-align the text in the fourth column:
Instead of adding a class to each cell, we can add a data-attribute per column to the table itself:
<table data-c2="center" data-c4="end">
Then, in CSS:
[data-c2~="center"] tr > *:nth-of-type(2) {
text-align: center;
}
[data-c4~="end"] tr > *:nth-of-type(4) {
text-align: end;
}
Repeat for all columns.
Conclusion
And that concludes the guide to table styling.
I didn't cover colspan
, rowspan
, scope
and span
. If you want to dive more into these, I suggest reading the MDN page on tables.
Demo
I've made a single CodePen with a bunch of demos here:
Update
In the comments, RioBrewster wrote:
You don't need:
<colgroup><col><col><col><col></colgroup>
You do need:
<th scope="col">
for each of the column headers.
Let me answer that with an example. Say you want to highlight the last column. Using <col>
, you simply add a class:
<col class="highlight">
In CSS:
.highlight { background-color: HighLight; }
That returns:
On the other hand, if you're using:
<th scope="col" class="highlight">...</th>
You get:
So that clearly doesn't work. We must add something more.
See MDN's example. They add <td scope="row">
to all the first cells of each row to "highlight the column".
That way, or using a bunch of nth-
-selectors to highlight a column, is much more work than simply using the <col>
-tag.
So, IMO, it's not "either or", but rather "either and".
Posted on January 28, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.