Rotated Table Headers, Sans Trigonometry
Rotating table column headers is a classic CSS challenge. The common approach involves calculating offsets with trigonometry or hard-coding pixel values that break the moment your table content changes. Turns out, there’s a cleaner way: lean on the browser’s own layout engine and a couple of extra HTML elements to do the heavy lifting, leaving you with a fully responsive rotation that requires zero magic numbers.
Here’s the final CSS for the header cells — more on how we get there below.
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;
}
That CSS depends on a specific, but purposeful, HTML structure. You need three nested elements per header cell:
<th> > <div> > <span>
Let’s see why each layer is necessary.
Why Not Just Rotate the <th>?
Rotating the <th> element directly seems like the natural starting point, but it doesn’t work out well.
<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;
}
- The column’s width is still determined by the full, unrotated length of its header text. That makes columns as wide as the written-out header, which defeats the purpose of compact vertical headers.
- The border on the table cell is part of the table’s border rendering model. Rotating the cell does not rotate that border with it.
Inside Layers Solve Both
The intermediate <div> solves the border problem. Any border you put on a child element is no longer part of the table’s border collapsing. As for the width issue, a child with position: absolute is removed from the document flow, so it no longer contributes to the cell’s dimensions.
<th class="rotate"><div>Column header 1</div></th>
When rotating something with absolute positioning, referencing the parent <th> is easiest when you anchor it to a corner. Set the <div> to bottom: 0; left: 0;. Its bottom-left corner now rests on the left column border, halfway through it.
From there it seems you’d need to shift it down by a border’s height and right by a cell’s width. That introduces responsiveness problems until you recall why the <span> tag got involved.
Using a <span> to Do the Math
The <div> is intentionally left visually empty. Its only job is to capture the size of its parent cell. Set the <div>’s width to 100% and you have the column width as a CSS length you can use in transforms. Without that, you’d have to guess at pixel values.
<th class="rotate"><div><span>Column header 1</span></div></th>
With the size known, shift the element into place. The translation needs to shift the <div> across by one column width, minus half a border width to align its edge exactly with the column’s edge.
transform: translate( calc( 100% - var(--table-border-width)/2), var(--table-border-width));
For rotation, we want the top-left corner of the border to serve as the pivot. Because the <div> sits at the bottom left of its parent, the correct transform-origin is at the left edge, raised up by the height of the border.
transform-origin: 0%, calc(100% - var(--table-border-width));
This placement matters—it removes any gaps between the rotated header border and the column border below it.
What handles the actual visible text? Apply the rotation to the <div>, then absolutely position the <span> within it, letting it overflow its parent as needed:
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;
}
Accounting for Overlap
Since transforms apply after layout is complete, rotated headers will paint over whatever content surrounds them. That’s expected. Provide space in the layout for the extra visual height the headers now occupy. In practice, grouping the table and its title in a flex container and assigning the title a sufficiently large flex-basis gives the text a spot without colliding with the rotated cells.
#div-with-table {
display: flex;
flex-direction: column;
justify-content: space-around;
}
#title {
flex-basis: 140px;
}


