I've not found pure html/css solution that does the following:
- uses tables semantically
- fixes both header and column
- works with variable width columns
- works with vertical and horizontal scrolling
Here's something I hacked together that does work
http://codepen.io/saiidi/pen/mePdqo
The thing I don't like about it is that the content in the header th elements is duplicated. I think with a little additional hacking this could be fixed.
In the spirit of putting code in the answer, here's the code:
html:
<div class="scrolly">
<div class="scrollx">
<table>
<thead>
<tr>
<th class="sticky"><div>1</div></th>
<th class="sticky"><div>2</div></th>
<th>header 1<div>header 1</div></th>
<th>header 2<div>header 2</div></th>
<th>header 3<div>header 3</div></th>
<th>header 4<div>header 4</div></th>
<th>header 5<div>header 5</div></th>
<th>header 6<div>header 6</div></th>
<th>header 7<div>header 7</div></th>
<th>header 8<div>header 8</div></th>
<th>header 9<div>header 9</div></th>
<th>header 10<div>header 10</div></th>
<th>header 11<div>header 11</div></th>
<th>header 12<div>header 12</div></th>
<th>header 13<div>header 13</div></th>
<th>header 14<div>header 14</div></th>
<th>header 15<div>header 15</div></th>
<th>header 16<div>header 16</div></th>
<th>header 17<div>header 17</div></th>
<th>header 18<div>header 18</div></th>
<th>header 19<div>header 19</div></th>
<th>header 20<div>header 20</div></th>
<th class="fill"></th>
</tr>
</thead>
<tbody>
<tr>
<td class="sticky">hello 1</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 2</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 3</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 4</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 5</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 6</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 7</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
<tr>
<td class="sticky">hello 8</td>
<td class="sticky">hello</td>
<td>world 1</td>
<td>world 2</td>
<td>world 3</td>
<td>world 4</td>
<td>world 5</td>
<td>world 6</td>
<td>world 7</td>
<td>world 8</td>
<td>world 9</td>
<td>world 10</td>
<td>world 11</td>
<td>world 12</td>
<td>world 13</td>
<td>world 14</td>
<td>world 15</td>
<td>world 16</td>
<td>world 17</td>
<td>world 18</td>
<td>world 19</td>
<td>world 20</td>
<td class="fill"></td>
</tr>
</tbody>
</table>
</div>
</div>
css:
td, th {
padding: 5px;
white-space: nowrap;
}
td {
background: linear-gradient(135deg, white 0%, #a80077 99%, black 100%);
}
th {
height: 0;
font-weight: normal;
}
.scrollx {
max-width: 100%;
overflow-x: scroll;
}
.scrolly {
position: relative;
max-height: 150px;
overflow-y: scroll;
margin-bottom: 20px;
}
table {
border-collapse: collapse;
min-width: 100%;
}
table td.fill,
table th.fill {
width: 100%;
min-width: 0;
background: white;
padding: 0;
}
tr {
position: relative;
}
.sticky {
text-decoration: underline;
font-weight: 700;
}
.stuck {
position: absolute;
}
thead th {
position: relative;
}
thead th div {
padding: 5px;
background: linear-gradient(135deg, black 0%, #a80077 99%, white 100%);
color: #ffffff;
position: absolute;
z-index: 2;
top: 0;
left: 0;
right: 0;
}
js:
$(function() {
$("#status").text('loaded');
$("table").each(function(index, table) {
var firstRow = $($(table).find('tr')[0]);
var offset = 0;
var stickies = firstRow.find('.sticky');
firstRow.children().each(function(index, td) {
var width = $(td).width();
$(table).find('tr td:nth-of-type('+(index+1)+')').css({width: width + 'px'});
$(table).find('tr th:nth-of-type('+(index+1)+')').css({width: width + 'px'});
});
stickies.each(function(index, td) {
var column = $(table).find('tr .sticky:nth-of-type('+(index+1)+')');
column.css({left: offset+'px'});
column.addClass('stuck');
offset += $(td).width() + 10;
});
$(table).parent().css({"margin-left": offset+'px'});
$(table).parent().parent().scroll(function(e) {
var top = e.currentTarget.scrollTop;
$(table).find('thead tr th div').css({top:top+'px'});
});
});
});
So, what's this doing?
it's using absolute positioning on the fixed (or "sticky") columns to keep the stuck on the left. To support variable widths, it's calculating the width before setting the position to absolute. Then a margin is applied to the left of the table container to compensate for the absolutely positioned columns.
For fixing the header, it's absolutely positioning the nested divs on the th elements, and then adjusting their "top" css property whenever the table is scrolled. Why the duplicated content? It's there so that the column width correctly takes into account the width of the header content.
This is a pretty rough implementation - the jquery code here is meant to be a proof of concept.