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".
Top comments (26)
Thank you for the writeup. Maybe we should have some interactive table wizzard to manage those options.
I recognized that things get even trickier if you want cell selection and a hover effect for tables. Any recommendations for this?
You mean selecting the text within a cell or editable cells? You can add a
contenteditable
attribute to each cell, orcontenteditable=plaintext
. However, this is easier to control from script. When you click on a cell, you can get thecellIndex
of theevent.target
, and therowIndex
from it's parent.There are some interactive table generators like this or this, but they do not seem to use the full potential of CSS. There are so many options we can use.
I am also struggling with cell selection by click. Contenteditable makes the content ediable, but often you just want so select a cell. Applying an effect on hovering is simple, but how to show a selection? See this example
Still not completely sure what you mean! If you want to add a visual clue to the "active cell being edited", either add and remove a class dynamically from JS, or use
td:focus
ortd:focus-visible
in CSS. If you addcontenteditable
directly on cells instead of the whole table, you cantab
through the cells and edit as you like (but better to do in JS!). If you want to select all text on selection, look into Selection and Range.So:
And:
But again: better to control in JS!
See this example
If you click on a cell, it stays selected. If I select another cell, the previous selection is removed. But this is a library that is quite heavy and relies on jQuery, so I would like to have a solution with CSS only.
I tried td:focus, td: visited, but neither seems to have any effect. "active" works only while i press the mouse only. Surely I can use Javascript, but as anything else is done in CSS, this is kind of awkward.
Tables might also have some options like:
This are options you usually find on data grids in most programming languages, but HTML does not seem to have any option for this. But how can we prevent to implement all this by hand or - at least - make it pretty lightweuight?
The example you provided does exactly what I wrote: Adds a class to the cell. You can hack this with
contenteditable
per cell and:focus
, but it's not really recommended. For selecting a row, you could add a checkbox to the first cell per row, and then use CSS:Some Jyvascript may do the trick, but it´s not really handy:
see example
You can also check out the styled table in my old project.
I was a beginner at that time, so I may not have been aware of these awesome tips you mentioned. Repository on GitHub
Great info. Would be good to cover making tables responsive (fluid) in a follow-up. If you learn grid first, then tables can feel rigid and uncompromising!
Good idea! I was also planning to write an article on how to navigate tables/datagrids with keyboards, following W3C's standard.
You don't need:
<colgroup><col><col><col><col></colgroup>
You do need:
<th scope="col">
for each of the column headers.And you would do better to make "Known as" the first column, and make that
<tr><th scope="row">Batman</th><td>Bruce</td><td>Wayne</td><td>Gotham City</td></tr>
This is how you make the table accessible to screen reader users.
See my reply in the update.
This post is so wonderful, turns out I have been doing it wrong ever since, some guys tell me that do not using raw HTML
Cool — happy to hear that!
Thanks for sharing. This post is very enlightening about styling tables. Very cool.
Thank you!
I wish I had known this earlier, well no knowledge is wasted. Great guide, btw.
Thanks!
Now I feel I need more CSS in my life. Great article!
Thanks!
Loved the post ! I recently started writing online and appreciate the quality of this article!
Thank you!
So... what if you want a responsive table?
I have to write part 2, I guess
Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more