Skip to main content.

CSS can't cope with rowSpan

CSS is fundamentally unable to cope with the rowSpan attribute on table cells. A lot of common approaches to styling tables simply don't work once this attribute comes into play — at least, not with a pure CSS approach. This compounds the problems caused by border-collapse: collapse and border-radius being incompatible with one another.

An introduction to border-collapse

In HTML, by default, tables have separated borders. If you have a four-by-four table — four cells wide; four cells high — and you put a border on each cell, the resulting table will look like four outlined boxes, slightly separated from one another.

If you add a border to the table as a whole, and use the outset style for the table and the inset style for the cells, then you get what was (to my recollection, anyway) the typical look for tables in the 1990s. In fact, I believe these styles used to be the default whenever you set the deprecated border="1" attribute on a table.

Let's deconstruct this effect, if only so that blind readers using text-to-speech browsers can follow along. The inset and outset effects produce a basic shadow effect that creates the illusion of depth. Sighted users will look at a table like this and see a table with white borders between the cells, with these borders seeming to be very slightly raised off of the page — a cheap, basic 3D effect. Another way to think of it is as if the table as a whole is raised slightly off the page, and the cells in the table are then sunken back onto the page.

That's what's happening visually: the cells share white shadowed borders that "collapse" together rather than remaining separate. What's happening on a technical level, of course, is the exact opposite. The table and its cells have separate (i.e. non-collapsing) borders that are colored in different shades of grey: these produce a shadow effect. The space between these borders, where the page background shows through, is what we visually perceive as the white border that is casting and receiving these shadows.

The default table styles, then, lend themselves very well to this sort of table border. We use technically-separate borders to fake a depth effect on visually-collapsing borders. What happens, then, if we want a collapsing border without this 3D effect? For that, we use CSS's border-collapse property. If we give our table cells a simple one-pixel border, then applying border-collapse: collapse to the table will produce a result wherein the cells are separated by a single line: each cell shares a border with the cells adjacent to it.

As a nice bonus, we can even use different border styles on two adjacent cells, and the browser will merge them intelligently. For example, suppose we have a table with just two cells, one above the other. Both of these cells have single-pixel black borders, except that the lower cell has a three-pixel red top border. When two cells need to share a border, but they want different colors or styles for that border, the CSS spec defines an algorithm by which browsers will try to identify which of the cells' style is more prominent, and this is the border that is kept. In our example, we see a red border between the two cells: the upper cell's thick red top border takes priority over the lower cell's thin black bottom border.

So collapsing borders are pretty neat!

Let's round off those corners

In CSS, we can give any element rounded corners by applying the border-radius property to it. Naturally, this extends to tables. We can take a table with separated borders and give it a border radius, and it'll just work... sort of. If we apply border-radius to the table, and give the cells their own border, then the cells' borders will remain square and protrude out of the rounded table. Our cells don't have a background color, so the result is that you see the table's rounded border beneath the cell, overlapping the cell's content.

So that sucks. And sadly, not only are collapsing borders no better in this regard; they're actually worse: setting border-collapse: collapse will forcibly disable border-radius on the entire table and all of its cells!

Emulating collapsed borders

The only way to have both collapsing borders and a rounded table is to emulate it ourselves. This means manually forcing cells' border widths to zero based on what they're adjacent to. Let's start with a basic example: another four-by-four table, with single-pixel black borders. Let's suppose we use the following CSS to collapse the borders:

table {
   border-collapse: separate;
   border-spacing:  0;
}
:is(td, th) {
   border: 1px solid #000;
}
:is(td, th) + :is(td, th) {
   border-left-width:0!important;
}
tr + tr > :is(td, th) {
   border-top-width:0!important;
}

Given any table cell C: if C has a cell on its left, then C's left border width will be forced to zero; and if C has a cell above it, then C's top border width will be forced to zero. This means that visually, it will share its top and left borders with adjacent cells.

For a basic table, with just ordinary rows (TR) and cells (TD), the result works pretty well. Browsers automatically wrap our table content in a tbody if we don't do so ourselves, so we have to account for that when writing our styles — we can't select TR elements as if they're direct children of the table.

While we're on that subject, what happens if we add a thead to the table?

Ah, that doesn't work so well. We're using sibling selectors on the table rows, but since we're using "official" table section elements — thead and tbody — the rows in each of those aren't siblings. As a result, the bottom border on the header row fails to collapse with the top border on the first body row. (For sighted users, I've shown two tables; the one on the right thickens the affected borders, and gives them bright contrasting colors. This same convention will be used a few more times.)

We need a new approach:

table {
   border-collapse: separate;
   border-spacing:  0;
}

:is(td, th) {
   border: 1px solid #000;
}

:is(td, th) + :is(td, th) {
   border-left-width:0!important;
}

:is(
   tr + tr,
   :is(tbody, thead) + :is(tbody, thead) > tr:first-child
) > :is(th, td) {
   border-top-width:0!important;
}

Ah, that works better. Those borders collapse properly now. As a benefit, with the way we wrote the styles, if we divide a table into multiple body sections for some reason, things will still work.

What if we want a different color for the table as a whole, versus its cells, though? Let's set the cell color to a dark grey, and give the table a 1px solid black border.

Hm, that doesn't really work. The cell borders don't collapse into the table borders. Handling this is going to get pretty ugly...

table {
   border-collapse: separate;
   border-spacing:  0;
   
   border: 1px solid #000;
}

:is(td, th) {
   border: 1px solid #999;
}

/*
   Collapse adjacent cells' borders together:
*/
:is(td, th) + :is(td, th) {
   border-left-width:0!important;
}
:is(
   tr + tr,
   :is(tbody, thead) + :is(tbody, thead) > tr:first-child
) > :is(th, td) {
   border-top-width:0!important;
}

/*
   Collapse cells' borders into the table border:
*/
:is(tbody, thead):first-child > tr:first-child > :is(th, td) {
   border-top-width:0!important;
}
:is(tbody, thead):last-child > tr:last-child > :is(th, td) {
   border-bottom-width:0!important;
}
:is(th, td):first-child {
   border-left-width:0!important;
}
:is(th, td):last-child {
   border-right-width:0!important;
}

Let's take a look:

Ah yes, that seems to work perfectly. Black borders around the table; grey borders between cells; but the grey borders don't double up with the black borders, nor with each other. I think we've solved this problem quite nicely: we've found a way to emulate the effects of border-collapse: collapse entirely by hand, with no downsides whatsoever. That's splendid.

Oh, uh, just one more thing. What happens if one of our cells uses rowSpan and extends down to the bottom of the table?

Ah, right. The bottom border of the row-spanned cell won't collapse with the bottom border of the table, so the two borders double up. The row-spanned cell stretches into the bottom row, but the element itself is located in one of the rows above that.

CSS has no way to know that this cell is part of the bottom row of the table. There's no way to discern that from the element hierarchy alone, because CSS selectors can only go forwards and inwards, not backwards or outwards. We have combinators for the next sibling (+), all next siblings (~), for child elements (>), and for descendants (whitespace); but nothing for previous siblings, parents, or ancestors. What's more: even if we could navigate, within a selector, from the row-spanned cell to its parent row, and then advance over the next rows — and let's not even consider the complications involved in dealing with additional thead and tbody elements! — CSS just doesn't have the logic needed for us to extract the value of the cell's rowSpan attribute, and then check for that many (or fewer) additional rows, to know if the row-spanned cell extends to the bottom of the table.

And that's without even getting into combinations of multiple row-spanned cells, or the ways that col-spanned cells could displace row-spanned cells. The simple fact of the matter is that there is no pure-CSS solution to this problem. But let's move on for now, and tackle border-radius. Put a mental pin in this whole problem, though, because we're going to have to come back to it.

Forwarding border-radius to cells

As we explored earlier, setting a border-radius on a table will round off the table, but not its contained cells. This, of course, won't produce the effect that we typically want when we use that CSS property. To work around this, we need to manually forward the border radius from the table to its cells.

We can add a radius to the table — and its corner cells — using the following CSS. Table rows don't apply border-radius, so we can have them inherit the value from the table, and then have cells inherit just the corner radii they need.

table {
   border-radius: 1em;
}
tbody, thead, tfoot, tr {
   border-radius: inherit;
}

tr:first-child > :is(td, th):first-child {
   border-top-left-radius: inherit;
}
tr:first-child > :is(td, th):last-child {
   border-top-right-radius: inherit;
}

tr:last-child > :is(td, th):first-child {
   border-bottom-left-radius: inherit;
}
tr:last-child > :is(td, th):last-child {
   border-bottom-right-radius: inherit;
}

Let's try that on the table just before our row-span test: so, a table with a heading section and a body section.

Ah, right. The CSS we're trying here doesn't work with table sections. The header row's bottom border curves upward, and the first body row's top border would curve downward within the table were it not hidden by our attempts at emulating collapsed borders. Let's revise our radius styles a bit:

table {
   border-radius: 1em;
}
tbody, thead, tfoot, tr {
   border-radius: inherit;
}

:is(tbody, thead):first-child>tr:first-child {
   border-top-left-radius:  inherit;
   border-top-right-radius: inherit;
}
:is(tbody, thead):last-child>tr:first-child {
   border-bottom-left-radius:  inherit;
   border-bottom-right-radius: inherit;
}

tr>:is(td, th):first-child {
   border-top-left-radius:    inherit;
   border-bottom-left-radius: inherit;
}
tr>:is(td, th):last-child {
   border-top-right-radius:    inherit;
   border-bottom-right-radius: inherit;
}

And let's see how that looks:

That's a lot better. Now let's ruin everything by using rowSpan.

The row-spanned cell extends into the bottom row, but the TD element itself is located in one of the prior rows. There is no way to tell, with CSS, that this cell is in the bottom row, and just like with border-collapse emulation, this is a problem for using border-radius. We have no pure-CSS way to tell this cell to round off its bottom-left corner, so the cell isn't rounded. The cell's bottom border protrudes out of the table's rounded bottom-left corner. What's more: if this cell had a background color, then that background color would poke out of the table and produce a rectangular corner as well.

There's one more problem that shows up if we put background colors on our cells. Our row-spanned cell stretches downward, displacing cells in lower rows to the right. However, as far as the DOM is concerned, those cells are the leftmost cells in their row. This means that if a row-spanned cell stretches to the bottom row of the table, the table's border-bottom-left-radius will be inherited by the next-leftmost cell in the bottom row. This isn't visible with the styles above, since our border-collapse emulation hides that cell's left and bottom borders; however, if we give our table cells a background color, the problem becomes apparent immediately.

There simply is no way to solve this. It's simply impossible for us to traverse the hierarchy using CSS and identify when a row-spanned cell extends into the bottom row of a table section, or of an entire table.

What could fix this?

Honestly, I think the only thing that could ever remedy this would be pseudo-classes like :bottom-row-cell. I'm not a browser implementer, though, so I don't know how feasible it would be to add something like this. A few possible concerns:

  • Some languages display text in a different direction or even an entirely different orientation. Newer CSS APIs are named with this in mind, referring to the "block axis" and the "inline axis" rather than the "vertical" and "horizontal" axes. How do these sorts of localization concerns interact with table layouts?
  • Should a pseudo-class like this be made available to non-table elements that use CSS display: table and friends? Would that be feasible?

In the meantime, workarounds that aren't pure-CSS exist. Row-spanned cells that stretch to the bottom row can be given a CSS class name which indicates that they belong to the bottom row; the leftmost cells that they displace, in lower rows, can be given a CSS class name which indicates that they do not belong to the leftmost column. However, the styles needed for border-collapse emulation and border-radius forwarding will need to be made more complex in order to allow for this.