Rotated <table>
column headers is something that’s been covered before right here on CSS-Tricks, so shout-out to that for getting me started and helping me achieve this effect. As the article points out, if you aren’t using trigonometry to calculate your table styles, you’ll have to rely on magic numbers and your table will be brittle and any dreams of responsiveness crushed.
Fortunately, in this case, we can take the trigonometry out and replace it with some careful geometry and our magic numbers all turn into 0 (a truly magical number).
For those in a hurry, here is the CSS (it’s very similar to the styles in the other article). Below is a thorough walk-through.
<th class="rotate"><div><span>Column Header 1</span></div></th>
table {
border-collapse: collapse;
--table-border-width: 1px;
}
th.rotate {
white-space: nowrap;
position: relative;
}
th.rotate > div {
/* place div at bottom left of the th parent */
position: absolute;
bottom: 0;
left: 0;
/* Make sure short labels still meet the corner of the parent otherwise you'll get a gap */
text-align: left;
/* Move the top left corner of the span's bottom-border to line up with the top left corner of the td's border-right border so that the border corners are matched
* Rotate 315 (-45) degrees about matched border corners */
transform:
translate(calc(100% - var(--table-border-width) / 2), var(--table-border-width))
rotate(315deg);
transform-origin: 0% calc(100% - var(--table-border-width));
width: 100%;
}
th.rotate > div > span {
/* make sure the bottom of the span is matched up with the bottom of the parent div */
position: absolute;
bottom: 0;
left: 0;
border-bottom: var(--table-border-width) solid gray;
}
td {
border-right: var(--table-border-width) solid gray;
/* make sure this is at least as wide as sqrt(2) * height of the tallest letter in your font or the headers will overlap each other*/
min-width: 30px;
padding-top: 2px;
padding-left: 5px;
text-align: right;
}
Let’s unpack this table and see what’s going on. The magic starts with that funny chain of HTML tags. We’re putting a <span>
inside of a <div>
inside of our <th>
. Is this all really necessary? Between how borders behave, the positioning flexibility we need, and what determines the width of a table column… yes, they each have a purpose and are necessary.
Let’s see what happens if we rotate the <th>
directly:
<th class="rotate">Column header 1</th>
table {
border-collapse: collapse;
}
th.rotate {
border-bottom: 1px solid gray;
transform: rotate(315deg);
white-space: nowrap;
}
td {
border-right: 1px solid gray;
min-width: 30px;
padding-top: 2px;
padding-left: 5px;
text-align: right;
}
Ignoring the fact that we haven’t corrected position, there are two big issues here:
- The column width is still calculated from the header length which is what we were trying to avoid.
- Our border didn’t come with us in the rotation, because it is actually part of the table.
These problems aren’t so difficult to fix. We know that if the <th>
has a child element with a border, the browser won’t treat that border as part of the table. Further, we know that absolutely-positioned elements are taken out of the document flow and won’t affect the parent’s width. Enter <div>
tag, stage left…and right, I guess.
<th class="rotate"><div>Column header 1</div></th>
table {
border-collapse: collapse;
}
th.rotate {
white-space: nowrap;
position: relative;
}
th.rotate > div {
position: absolute;
transform: rotate(315deg);
border-bottom: 1px solid gray;
}
td {
border-right: 1px solid gray;
min-width: 30px;
text-align: right;
padding-top: 2px;
padding-left: 5px;
}
It’s easier to tell in the image with the rotated <th>
elements, but that rotation is happening around the center of the element (that’s the default behavior of transform-origin). It is only another transform in x and y to get it to the right spot, but this is where we’d need trigonometry to figure out just how much x and y to line it up with the column borders. If we instead carefully choose the point to rotate the header about, and use transform-origin to select it, then we can end up with distances that are more straightforward than magic numbers.
The animation below helps illustrate what we’re going to do to avoid complicated math. The black dot in the top left of the blue border needs to match the red dot on the right border of the table column and rotate about it. Then there won’t be any gaps between the two borders.
It’s not helpful to start going somewhere if you don’t know where you are. The absolute positioning is going to help us out with this. By specifying bottom: 0; left: 0;
on the <div>
, it ends up at the bottom left of the parent <th>
. This means the <div>
border’s bottom-left corner is sitting on top of the left column border and halfway through it. From here, it’s apparent we need to move down one border width and over one cell width, but how are we going to get that responsively? It’s at this very moment you may recall that we haven’t added the <span>
yet — we’re going to need it!
We’ll use the <div>
to “figure out” how big the table cells are and the <span>
to actually hold the text and position it absolutely as well to overflow the parent.
<th class="rotate"><div><span>Column header 1</span></div></th>
th.rotate{
white-space: nowrap;
position: relative;
}
th.rotate > div {
position: absolute;
bottom: 0;
left: 0;
width: 100%; /* <- now the div parent is as wide as the columns */
}
th.rotate > div > span {
position: absolute;
bottom: 0;
left: 0;
border-bottom: 1px solid gray;
}
Great! When we set the width of the <div>
to 100%, it holds the information for how big the column is regardless of what the content is in the table cells. With this in place, we can easily translate things over by the width of the <div>
— but don’t forget that we need to shave off a half border width. Our translation becomes:
transform: translate( calc( 100% - var(--table-border-width)/2), var(--table-border-width));
The <div>
is now in the right spot to rotate, but we have to make sure to pick the correct transform-origin. We want it to be on the top-left corner of the border, which will be on the left and up one border’s width from the bottom of our <div>
element:
transform-origin: 0%, calc(100% - var(--table-border-width));
This brings us to our final style for the table header.
table {
border-collapse: collapse;
--table-border-width: 1px;
}
th.rotate{
white-space: nowrap;
position: relative;
}
th.rotate > div {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
transform:
translate( calc( 100% - var(--table-border-width)/2), var(--table-border-width));
rotate(315deg);
transform-origin: 0%, calc(100% - var(--table-border-width));
}
th.rotate > div > span {
position: absolute;
bottom: 0;
left: 0;
border-bottom: var(--table-border-width) solid gray;
}
Note that transformations happen after everything is placed. That means the rotated headers will overflow onto everything as best they can. You will need to wrap the whole table in something to compensate for the unexpected height. I put the title and table together in a flexbox <div>
and set the flex-basis
of the title to a value large enough to compensate for the tall headers.
#div-with-table {
display: flex;
flex-direction: column;
justify-content: space-around;
}
#title {
flex-basis: 140px;
}
Interesting and thorough article thanks :)
If the thead text is just to be vertical (not slanted) then you can use writing-mode to good effect without absolute positioning or magic numbers (apart from line-height:0).
(This demo is a couple of years old so could be improved a little.)
This will break if the thead text contains <b> or <p> .
Here is an other solution with no css but only javascript.
https://github.com/napengam/vertical
Ah yes, I love write mode and how easy it makes vertical column headers. If only there were some culture that wrote with 45 degree inclined lines!
This is awesome! Thanks for the great reference.
Yes my demo was only meant for a single line. ;) I think if you have multiple lines then it starts to look very messy but each to their own.
Your JS version looks good and useful :). The only quibble I have (apart from the use of js) is that it doesn’t stand up to text-zoom only and content will overlap (unlike my example). The CSS tricks demo will also suffer from this.
Hi Paul
can you please explain what you mean by …
“… it doesn’t stand up to text-zoom only and content will overlap… ”
Regards
Hi Heinz,
I don’t want to derail this discussion form the original topic (although the answer may also be relevant to the article) but in answer to your question I have my Firefox set to zoom ‘text only’ as I find it easier to read like that (rather than zooming the whole layout). When I zoom your example the text will overlap as you have placed the text in fixed height containers with your JS. (Of course I realise you could cater for this with more JS and detect zoom etc.) :)
less magic:
That does get you almost all the way there! I debated discussing this one, but because you’ve set the width of the
<div>
to 100% that means your border will only ever be the width of the cells. If it’s not important that your entire header is underlined (or in this case you don’t have a bottom border at all) then this is certainly the way to go, but keep in mind if you do add/want a bottom border they will be different lengths based on the cell content without the<span>
.