There is no box model in CSS — and this is why HTML tables are a disgrace

JavaScriptUI — DevBlog #9

Bence Meszaros
19 min readJul 4, 2024

This is the third and last installment in this mini-series that aim to raise awareness of how fundamentally broken the CSS “box” model is. In the first article I demonstrated this with simple borders, in the second, I demonstrated this with visual layers and in this third article I will demonstrate this using HTML/CSS tables.

Disclaimer: This is a long article on a difficult topic. Please bear with me until the end; I think it’s worth it, especially if you ever struggled with tables. At least now you'll know why.

Our guinea pig

For our demonstration we are going to build a simple table. Nothing fancy as always, just a simple, rudimentary example:

our basic table design

We have a light gray background with some padding and rounded corners, a header row with a bottom border as separator and six normal rows with alternating backgrounds.

To be a bit more specific, we need 50px padding on our table, each row has a height of 40px and there are 10px gaps at the beginning and end of a row and in between columns. This is our annotated layout:

the annotated layout

So, let’s try to build this in HTML/CSS using the “box” model and see what’s what.

Phase one — Building the skeleton

The first step is to create the same visual hierarchy in HTML and add some style and layout information:

<body style="font-family: Inter; font-size: 14px; color: rgb(102, 102, 102); width: 1000px; height: 500px; background-color: rgb(102, 102, 102); display: flex; flex-direction: row; justify-content: center; border: 1px solid red;">
<table style="padding: 50px; border-radius: 30px; background-color: rgb(230, 230, 230);">
<tr style="border-bottom-width: 1px; border-bottom-style: solid; border-bottom-color: rgb(179, 179, 179); height: 40px;">
<td>Character</td>
<td>First seen in</td>
<td>Allegiance</td>
</tr>
<tr style="background-color: rgb(255, 255, 255); height: 40px;">
<td>Space Beth</td>
<td>S4 E10</td>
<td>friend</td>
</tr>
<tr style="background-color: rgb(204, 204, 204); height: 40px;">
<td>Pencilvester</td>
<td>S2 E4</td>
<td>foe</td>
</tr>
<tr style="background-color: rgb(255, 255, 255); height: 40px;">
<td>Tammy</td>
<td>S1 E5</td>
<td>foe</td>
</tr>
<tr style="background-color: rgb(204, 204, 204); height: 40px;">
<td>Mr. Poopybutthole</td>
<td>S2 E4</td>
<td>friend</td>
</tr>
<tr style="background-color: rgb(255, 255, 255); height: 40px;">
<td>Birdperson</td>
<td>S1 E11</td>
<td>friend</td>
</tr>
<tr style="background-color: rgb(204, 204, 204); height: 40px;">
<td>Tinkles</td>
<td>S2 E4</td>
<td>foe</td>
</tr>
</table>
</body>

This code is pretty unreadable as is, and since we are here to learn about JavaScriptUI after all, I will use JavaScriptUI syntax from now on. JavaScriptUI works the exact same way as an HTML tree with inline styles, but it gives us a much more readable experience. So here it is, the same example:

Body(
Table(
Row(
Cell("Character"),
Cell("First seen in"),
Cell("Allegiance")
)
.borderBottom("1px solid #B3B3B3")
.height(40),
Row(
Cell("Space Beth"),
Cell("S4 E10"),
Cell("friend")
)
.backgroundColor("#FFFFFF")
.height(40),
Row(
Cell("Pencilvester"),
Cell("S2 E4"),
Cell("foe")
)
.backgroundColor("#CCCCCC")
.height(40),

//rest of the rows are redacted

)
.padding(50)
.cornerRadius(30)
.backgroundColor("#E6E6E6")
)
.width(1000)
.height(500)
.backgroundColor("#666666")
.display("flex")
.border("1px solid red"); //added temporarily for debugging purposes

And our beautiful result is this:

default layout, courtesy of CSS

For some reason, this is the default flexbox behavior. The left alignment is somewhat expected, but this absurd vertical stretch is pretty weird, especially the bottom overflow. And even though we set explicit height for all of our rows, CSS seems to think that these are more like suggestions, not actual layout rules.

Height?

We can try setting max-height on our rows or setting height and/or max-height on our cells, but nothing will happen, CSS will basically ignore these values. However, height works again on the table element, but ironically this is the only place where we don’t want an explicit value.

Probably my biggest issue with the “box” model, and with CSS in general, is its opaque nature. If I set an explicit height of 40px, I expect that that element is going to be 40px, no matter what. You can say, that height: auto, or height: max-content, or height: lollipop means something dynamic and that the height might change, but if I set height: 40px, especially when I also set max-height: 40px, and the height is not 40px, then it is not a steep learning curve but a fundamentally broken language.

Seems like it would be much more accurate to rename the “box model” to “suggestion model”, or even “talking to a brick wall model”.

apparently, most of our CSS “code”

Note: the name “flexbox” is just as incorrect as the name “box model”. There are no boxes in CSS: no inline boxes, no block boxes and no flexible boxes, only rectangles. Our layout is always forced into a 2D plane by default. This is especially painful, because flex containers are similar to Stacks for example in SwiftUI, but they can only mimic vertical and horizontal Stacks, not ZStacks — something, that would actually resemble a box and would be awesome to have on the web.

Anyway, our best option is to use vertical and horizontal alignments provided by the flex container, like this:

Body(/*...*/)
.width(1000)
.height(500)
.border("1px solid red")
.backgroundColor("#666666")
.display("flex")
.justifyContent("center") //horizontal alignment
.alignItems("center"); //vertical alignment

Note: Vertical and horizontal alignments in flex containers are also using incomprehensible names, like justify-content or align-items. I aim to replace these with proper dimensional alignments like alignX and alignY. For now, we need to cope with the default properties.

This code finally yields proper alignments and sizes:

proper alignment, proper size

Yay. And we haven’t even started with the table itself.

Phase 2 — Removing spacing between rows

If you take a look at our current result, you’ll notice that, by default, there are 2px gaps between rows and between columns. In the original design we defined 10px spacing between columns and zero spacing between rows, so we need to adjust them. First, let’s see how we can remove the default spacing between our rows.

Margin, border or padding?

In our first article we discussed that there are three “boxes” in the CSS “box” model — the margin “box”, the border “box” and the padding “box” — but don’t bother trying to set margin, border or padding on any of our elements, none of them will work to remove these gaps. In fact, margin, border and padding are completely ignored for table rows (as well as for table columns), as you can see from the missing separator below our header row. But margin is also ignored for table cells, and if you set border on cells, you end up with double borders:

double the borders, double the fun

Beautiful, isn’t it?

Border-box?

But wait, we already have an exception for borders in the “box” model. What if we simply change box-sizing from content-box to border-box? Would that solve this issue? Of course not. The box-sizing property is basically ignored in tables, just as margin, border and padding on table rows and table columns.

Gaps?

OK, so what about row gaps? This property would make the most sense, since these gaps are between rows and therefore they belong to neither of the neighboring rows. In addition, other layouts in CSS like multi-column, flex and grid are also using this property. Well, unfortunately reason is something CSS doesn’t do. There is no row-gap property for tables (or even column-gap as a matter of fact), setting this will not do anything.

Border-spacing?

As it turns out, tables have a special, unique property for setting these gaps, and it is called border-spacing. This property can set the vertical and horizontal spacing between rows and columns.

Note: Even though border-spacing accepts two values (horizontal and vertical border spacing), it is not a shorthand property (there are no constituent properties). This is already a departure from the implementation of gaps in CSS.

So, let’s remove our cell borders and set the horizontal and vertical spacing on our table:

Body(
Table(/*...*/)
.padding(50)
.cornerRadius(30)
.backgroundColor("#E6E6E6")
.borderSpacing(10, 0) //set horizontal and vertical gaps
)
.width(1000)
.height(500)
.backgroundColor("#666666")
.display("flex")
.justifyContent("center")
.alignItems("center");

And the result is this:

so close, yet still so far away

The vertical part of our layout is almost done, except we are still missing the separator under our header row. We’ll get back to that later.

Phase 3 — Adding spacing between columns

Now here comes the real chaos. The problem with border-spacing is twofold:

  • first, the spacing is added before the first row/column and after the last row/column as well (leading/trailing or outer spacing)
  • second, spacing doesn’t have the border and background of the row/column and there is no way to fix that

Leading and trailing border-spacing

Unlike gaps in flex or grid containers, border-spacing injects a leading and trailing space as well. This is not just another quirk compared to the gap property, but a bad design, because it prevents us from setting different values for outer and inner spacing. We can try to compensate with less padding on our table, but this hack is exhausted once we require larger inner spacing than the table padding.

Take a look at this exaggerated example, where we need 50px padding all around our table and 120px between columns. Using our padding hack we extract the 120px border-spacing from our 50px left/right padding to account for the extra leading/trailing spacing and we end up with a negative padding value, which is invalid:

//50px paddingLeft - 120px borderSpacing = -70 paddingLeft (invalid)

Body(
Table(/*...*/)
.padding(50, -70, 50, -70) //negative padding is invalid
.cornerRadius(30)
.backgroundColor("#E6E6E6")
.borderSpacing(120, 0) //horizontal and vertical border-spacing
)
.width(1000)
.height(500)
.backgroundColor("#666666")
.display("flex")
.justifyContent("center")
.alignItems("center");

CSS clamps padding at 0px if we try to add negative values, so we end up with this:

50px top and bottom padding, 120px left and right “padding” (border-spacing)

Borders? Backgrounds?

But the bigger issue is the lack of border and background in these gaps, even though we explicitly set the border for the header row and the background color for all regular rows. Unfortunately, there is no solution for this, the only thing we can do is set border-spacing to zero and find an alternative way to add gaps between our cells:

table with zero border-spacing

Columns?

If border-spacing on our table cannot add proper column gaps, then what about using the <col> element? Would it be possible to set margin, border or padding on that?

Unfortunately, just like table row elements, column elements doesn’t have a margin, border, or padding, these properties are ignored. The only thing that seems to work on column elements is the width property, but that doesn’t help us here.

Padding, again?

One of our last resorts is brute force. By setting padding on each and every cell properly we can fake horizontal spacing. The drawback is that it is meticulous, even with CSS classes, and we need to halve column gaps to apply one half to the first cell and the other half to the second cell. With this hack, CSS literally shoots itself in the foot: the whole point would be to centralize settings as much as possible in order to avoid repeating yourself, but then it provides no centralized gap settings on rows, on columns, or even on tables.

Also, this solution is by no means semantic, and it is horrible to work with. But it is the best CSS can offer.

//this is needed for each and every cell in each and every row

Row(
Cell("Character")
.paddingLeft(10)
.paddingRight(5), //half
Cell("First seen in")
.paddingLeft(5) //half
.paddingRight(5), //half
Cell("Allegiance")
.paddingLeft(5) //half
.paddingRight(10)
)
.borderBottom("1px solid #B3B3B3")
.height(40)

Spacer?

Another last resort option is to use spacer columns to somewhat simplify the brute force approach discussed previously. In this solution, instead of faking gaps by half paddings, we fake them by inserting a blank column with the full width of the gaps. But this is not without problems either.

First, we cannot add columns with a set amount of width, the <col> element can only style existing columns. So, to fake column gaps, we need to add empty cells instead:

Row(
Cell().width(10), //fake leading gap
Cell("Character"),
Cell().width(10), //fake column gap
Cell("First seen in"),
Cell().width(10), //fake column gap
Cell("Allegiance"),
Cell().width(10) //fake trailing gap
)
.borderBottom("1px solid #B3B3B3")
.height(40)

Second, we lose the ability to load data directly into our table, because we need to manually inject blank cells in order to keep the layout from falling apart.

And third, missing cells will shift the entire table, so even if a single gap cell is missing, the whole table breaks (our gap cells are actually regular cells):

Row(
//Cell().width(10), //fake leading gap, missing by accident
Cell("Character"),
Cell().width(10), //fake column gap
Cell("First seen in"),
Cell().width(10), //fake column gap
Cell("Allegiance"),
Cell().width(10) //fake trailing gap
)
.borderBottom("1px solid #B3B3B3")
.height(40)

But on the plus side, we will definitely notice that something is off:

leading gap cell is missing in the header row

Whether we use half paddings or full spacers, at least we now have a somewhat correct design:

correct outer spacing, correct row/column gaps, correct table padding 🥳

The only thing missing is the separator line between the header and the first regular row. For that, we need to descend to the deepest circle of CSS hell.

Phase four — Adding a separator

Row and column separators are quite similar to row and column gaps (they should belong to neither of their neighbors), but because of this, they are just as broken in CSS as row and column gaps.

We already learned, that row elements cannot have borders, neither can column elements, so this leaves us with only two options:

  • use a bottom or top border on all of the cells in a row, or
  • add a spacer with a background, just like how we faked gaps

Borders, again?

We already know how bad it is that borders in the “box” model are calculated into the layout, but it turns out that tables do not have this issue, in fact, cells properly display their borders within themselves, and without affecting the layout.

For this reason, box-sizing doesn’t even do anything inside tables, but don’t celebrate just yet. The problem here is that neighboring cells do not share a single border, they are both rendered side by side instead. Just add a 1px solid black bottom border to the header cells and a 1 px solid red top border to the first row of regular cells:

Row(
Cell()
.width(10)
.borderBottom("1px solid black"),
Cell("Character")
.borderBottom("1px solid black"),
Cell()
.width(10)
.borderBottom("1px solid black"),
Cell("First seen in")
.borderBottom("1px solid black"),
Cell()
.width(10)
.borderBottom("1px solid black"),
Cell("Allegiance")
.borderBottom("1px solid black"),
Cell()
.width(10)
.borderBottom("1px solid black")
)
.borderBottom("1px solid #B3B3B3")
.height(40),

Row(
Cell()
.width(10)
.borderTop("1px solid red"),
Cell("Space Beth")
.borderTop("1px solid red"),
Cell()
.width(10)
.borderTop("1px solid red"),
Cell("S4 E10")
.borderTop("1px solid red"),
Cell()
.width(10)
.borderTop("1px solid red"),
Cell("friend")
.borderTop("1px solid red"),
Cell()
.width(10)
.borderTop("1px solid red")
)
.backgroundColor("#FFFFFF")
.height(40)

This yields the following design:

separator with borders

Let’s see how many issues this design has.

First, even though borders are rendered correctly inside the cells, they can still alter the size of our cells. If the combined width/height of the borders and the size of the text content is bigger than the explicit size set for the cell, the cell will ignore its size settings and grow anyway. This can lead to hard to detect layout anomalies.

Second, since CSS lacks border support for rows, we need to add borders to all of our cells instead. But since CSS also lacks support for proper column gaps, we need to add borders to the fake column gap cells as well, further complicating our code (just take a look at the code above).

And third, CSS doesn’t collapse neighboring borders by default, so we end up with two borders, instead of one. We can choose to add a full border to only one set of cells or to the other, or we can halve the separator width and add it to both. This is basically the same problem as faking column gaps with padding. Whichever we choose, the resulting code becomes a complete mess.

Then collapse the borders, duh

Sure thing, let’s see how that works. There is a property called border-collapse available only for tables, and which can have two values: separate and collapse. So, let’s set it to collapse and see what happens:

border-collapse: collapse

When I first saw this, I burst out of laughing. I don’t know what the “designers” of this property were thinking, but I would really, really like to ask them, because this is beyond comical. If you set border-collapse to collapse, it collapses all borders, even the table borders with cell borders, not just borders between neighboring cells. This means, that CSS cannot understand padding on the table anymore, so it is removed. In addition, it seems that rounded corners cannot round the background of table cells (in our example the bottom two corners are rounded too, they are just rendered underneath the last row of cells).

But the question now is this: if I set a border for both cells (or a cell and the table itself), which one is going to win? Well, in CSS, elements defined later are rendered on top of elements that are defined earlier. In this case, however, we see a black border, even though this border color is set for the header cells and they are clearly defined before the first row of regular cells. To make things worse, if we set the red border to be thicker than the black, then the red border wins. Splendid.

Note: Setting different border thickness within one row or one column produces even weirder results, but I don’t think that this is a normal use case, so I won’t go into details there. But this is another case for borders on rows/columns instead of only on cells.

Now, I know I am beating a dead horse here, but I have to point out that this property name and these values are ridiculous too. This is a boolean toggle that defines whether borders are collapsed or not, but since CSS has an eternal distaste for proper types, we are stuck with an enum with two string values. But even then, saying that the “border collapse is separate”, or the “border collapse is collapse” sounds ridiculous to me. We could have any of the following two options (or something similar), but CSS chose neither of them:

  • border-collapse: true/false
  • border-handling: separate/collapse

Spacer, again?

If you prefer using borders and/or table padding is not an issue for you (maybe you already have another container anyway) you can take your chances with border-collapse. But if you want something other than that, our last option is to try to fake separators with an empty row just like how we faked column gaps. The drawback is that now the height of our table will grow (similar to how the “box” model works when border-sizing is set to content).

This is our code to achieve this:

Row(
Cell()
.width(10),
Cell("Character"),
Cell()
.width(10),
Cell("First seen in"),
Cell()
.width(10),
Cell("Allegiance"),
Cell()
.width(10)
)
.height(40),

//adding the separator
Row()
.height(1)
.backgroundColor("black"),

Row(
Cell()
.width(10),
Cell("Space Beth"),
Cell()
.width(10),
Cell("S4 E10"),
Cell()
.width(10),
Cell("friend"),
Cell()
.width(10)
)
.backgroundColor("#FFFFFF")
.height(40)

//rest of the rows...

And this is our result:

???

Gotcha! Did you think this was going to work? But everybody knows that an empty row ignores its background property. Didn’t you read the specification?

Jokes aside, the sad fact is that this approach doesn’t work, not because height is ignored on empty rows — the row is actually there, the height of the table increased — but because we cannot set background color on them. But if you change the background color of the header row instead (that only has cells without a background), we can, in fact, set the background color for an entire row:

//no background on any of our cells
Row(
Cell()
.width(10),
Cell("Character"),
Cell()
.width(10),
Cell("First seen in"),
Cell()
.width(10),
Cell("Allegiance"),
Cell()
.width(10)
)
.backgroundColor("coral") //background only on row
.height(40)

This is the result:

Can rows have a background color? — Yesn’t.

Our options are limited: we can revert back to using borders with all their quirks, or we can add an empty cell into our empty row as well to enable the background color of our fake row:

Row(
Cell()
.width(10),
Cell("Character"),
Cell()
.width(10),
Cell("First seen in"),
Cell()
.width(10),
Cell("Allegiance"),
Cell()
.width(10)
)
.height(40),

//fake row
Row(
Cell("") //fake cell
)
.height(1)
.backgroundColor("black"),

Row(
Cell()
.width(10),
Cell("Space Beth"),
Cell()
.width(10),
Cell("S4 E10"),
Cell()
.width(10),
Cell("friend"),
Cell()
.width(10)
)
.backgroundColor("#FFFFFF")
.height(40)

//rest of the rows...
??????

GOTCHA AGAIN! This still doesn’t work, because row background is only rendered where cells are present. So, in order to draw a single separator line with an additional row, we also need to fill it with the same amount of empty cells as our regular rows, like this:

//fake row
Row(
Cell(""), //fake cell for fake leading column spacing
Cell(""), //fake cell for first column
Cell(""), //fake cell for fake column spacing
Cell(""), //fake cell for second column
Cell(""), //fake cell for fake column spacing
Cell(""), //fake cell for third column
Cell("") //fake cell for fake trailing column space
)
.height(1)
.backgroundColor("black")

And even then, you might notice that the height of this separator row is 2px, instead of 1px, because empty cells in CSS have a default 1px padding in all directions, which gives us a 2px cell height that overrides our explicit row height of 1px. Accounting for this as well, our final code is this:

//fake row
Row(
Cell("") //fake cell for fake leading column spacing
.padding(0),
Cell("") //fake cell for first column
.padding(0),
Cell("") //fake cell for fake column spacing
.padding(0),
Cell("") //fake cell for second column
.padding(0),
Cell("") //fake cell for fake column spacing
.padding(0),
Cell("") //fake cell for third column
.padding(0),
Cell("") //fake cell for fake trailing column space
.padding(0)
)
.height(1)
.backgroundColor("black")

And our final result looks like this:

the final result, finally

Now, you might be thinking that this is why inline styles are bad, and this code would actually look better using at least some CSS classes, but I disagree. Using these CSS tools we only obscure these issues even further. If we had only a single border property on table rows (and of course on table columns), none of this code would ever exist, not in JavaScriptUI, not in HTML, and not in CSS. There would be no need for external CSS codes, CSS classes, or anything else.

Only for a single line, like this: Row().borderBottom(“1px solid black”).

Why this isn’t the case is beyond me.

Let’s recap

Even though the basis of CSS is the “box” model, it is barely applicable to tables, everything is an exception of an exception. Tables can have margin, border and padding, but padding is ignored if borders are collapsed. Table rows cannot have margin, border or padding at all, their background is only visible if they have cells but without a background, and even then the row background is only visible under its cells not for the entire row. In addition, even if we set explicit height and even max-height for rows, a wide range of obscure CSS features can implicitly override this, such as having a flex parent for the entire table, having cells with borders or even having blank cells with their default padding. Table columns are only used for styling (they cannot insert blank or essentially any type of columns) but even then they cannot have margin, border or padding at all. Cells cannot have margin, and their borders work differently than the border of any other element in CSS: sometimes they are rendered on top of them, sometimes they aren’t depending on the content of the cell. In addition, cell borders are separate by default (they are essentially duplicated between cells) and if we collapse them, we also collapse the border of the table, which leads to the loss of padding on the table as already noted before. Collapsed borders do not follow the basic stacking rule: borders on cells defined earlier override borders on cells defined later. In addition, there is a 2px row and a 2px column gap in tables by default, but these aren’t governed by the box-sizing property or the row/column-gap property like in multi-column, flex and grid containers, but by an additional property called border-spacing. Border-spacing also adds leading/trailing spacing as well, it cuts rows and columns between cells, and it doesn’t even work at all if we need to collapse the borders, making this property essentially useless.

In essence, we cannot achieve proper row gaps, we cannot achieve proper column gaps, we cannot achieve proper row separators, we cannot achieve proper column separators and we cannot achieve proper table padding, everything is unnecessarily interconnected and override each other under the hood.

This chaos is even worse than the obscure nature of the cascade and specificity in CSS. And to think, just 15 years ago, tables were the best layout solution, even for entire websites.

A teeny-tiny rant

Now, I am really trying to contain my rage, but this piece of sh*t is a disgrace to developers, to designers, to programmers and in general, to humanity as a whole. I don’t care if you learned how to cope with all this garbage or you’ve read all seven thousand pages of the specification or you can create wonderful websites without any issues, the fact that something this fundamental is this broken is unacceptable. If anyone responsible for this is reading this, know that I have no beef with you, but you have to make this right, because this junk is inexcusable.

And to those, who still wonder why we need to completely redesign how tables work on the web: anything I showed you today is a major accessibility violation. Broken borders, spotty backgrounds, uneven row heights, asymmetrical paddings, I mean, really? Do you really think tables on the web are a solved problem?

</rant>

the CSS working group after implementing the 657th exception to the “box” model

Outro

This is all I have for you today, I hope you found it interesting and thank you for reaching the end of this pretty long and technical article. If you like what I am doing, please consider clapping, commenting and sharing my work with your friends and coworkers. It means a lot to me.

Thank you, and have a great week.

⬅️ DevBlog #8 — There is no box model in CSS — and this is why you struggle with visual layers

--

--