A Guide to Styling Tables
Tags: css, webdev, tutorial, showdev
Published: 2024-01-28 08:25:33

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:_
```html
<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:_

```html
<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:

![Basic Tables Browser Styles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/iq375md00ma4g8bikaqj.png)

The default user-agent-styles are:

```css
table {
  border-collapse: separate;
  text-indent: initial;
  border-spacing: 2px;
}
```

Now, if we add a super-simple rule:

```css
:is(td,th) {
  border-style: solid;
}
```

We get:

![Basic Table with solid border](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9o8sa2igklyxz78ec2ij.png)

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:

```css
table {
  border-collapse: collapse;
  font-family: system-ui;
}
```

... we get:

![border-collapse set to collapse](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/beia4yujksm8c5945o7s.png)

If we then add `padding: .5ch 1ch` to our `:is(td,th)`-selector and `margin-block: 1rlh` to `<caption>`, we get:

![Basic Table Styles](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6tai5jys59g9q0sg3t5w.png)

To recap, **all** we need to get the above styling, is this:

```css
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:

```css
table {
  caption-side: bottom;
}
```

---

## Zebra Stripes
To add odd/even zebra-stripes for **columns**, we can simply style the `<col>`-tag:

```css
col:nth-of-type(even) { background: #F2F2F2; }
```

![Col Zebra](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mlnajfbyvxsgzlgbntge.png)

For rows, it's similar:

```css
tr:nth-of-type(odd) { background: #F2F2F2; }
```

![Zebra Rows](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/p47mwf3x6dp8n5qit72d.png)

---

## 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:

```css
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:

```css
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:

![Rounded corners](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/172o5rqyidiqisopyrx6.png)

---

## Split Columns
Let's _keep_ the separate columns, and use the `border-spacing`-property to add a gap between columns:

```css
table {
  border-spacing: 2ch 0;
  & :is(td, th) {
    border-inline-width: 1px;
  }
}
```

![Split columns](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uplqw0uurk0yk7ot2k1k.png)

We can even add `border-radius`:

![Border radius](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ldecau6tm4elj68hz7id.png)

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:

```css
table {
  border-spacing: 0 2ch;
  & :is(td, th) {
    border-block-width: 1px;
  }
}
```

![Split Rows](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/q118cu966qesqqyir6gc.png)

---

## 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>`:

![Table Hover Example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/zy9wrlzfvt5us63ip8ae.png)

Hovering rows and cells is straightforward:

```css
td:hover {
  background: #666666;
}

tr:hover {
  background: #E6E6E6;
}
```

Hovering a `<col>` is a bit more complicated. 

You can add a rule:

```css
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:

```css
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:

```css
:is(td, th, tr):hover {
  outline: 2px solid #666;
  outline-offset: -2px;
}
```

![Table Hover: Outlines](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/e4ump8dctc5q6yrpdqif.png)

### Column Outlines

To outline a column is _very_ tricky, but looks nice:

![Table Hover: Outline Column](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/o8om2x4gtsizjl7bwj30.png)

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:

```css
: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:

```css
: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:

![Table: Align Text](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/659ffi0cieppfs3p9xvf.png)

Instead of adding a class to _each_ cell, we can add a data-attribute per column to the table itself:

```html
<table data-c2="center" data-c4="end">
```

Then, in CSS:

```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](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/table).

## Demo
I've made a single CodePen with a bunch of demos here:

{% codepen https://codepen.io/stoumann/pen/RwdVxJM %}

---

## 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:

```html
<col class="highlight">
```

In CSS:
```
.highlight { background-color: HighLight; }
```

That returns:

![Highlight](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b3enzchu8t4kwcgeasgo.png)

On the other hand, if you're using:

```html
<th scope="col" class="highlight">...</th>
```

You get:

![Highlight with scope](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/snhhfe85mesuzyimrofr.png)

So that clearly doesn't work. We must add something more. 

See [MDN's example](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/td). 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*".