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".