There is a plethora of options available when trying to solve the sticky table header problem. There's position fixed in a cloned header, full JavaScript solutions to keep the scroll offset and a mixed bag with many packages doing something in between.

All these solutions share the same problem: They remove the table headers from the browser's own width computation. If you have dynamic tables with cells that may change their widths as we do with our inline-editing functionality in OpenProject, the changing cells width will break the header widths since they are not in sync.

There is a draft spec for a stickily positioning an element: position: sticky.

A few years ago, position: sticky was available in Chrome when it still ran on webkit, and Firefox introduced support with v32.0 back in 2014.

Chrome now recently introduced support for position: sticky again in their own engine: Read the original blog post here.

The interesting part is, in Chrome, sticky works on table header cells as well. This makes it the easiest CSS-only solution to sticky table headers that do not have the above width synchronization problem. If you plan to use it, there are some hefty caveats, however. Primarily, this concerns browser support.

Browser Support

In current stable versions, position: sticky cannot be applied to the entire thead, but only on table cells. For sticky headers, you need to place the attribute on each individual <th>. There is an active bug for the thead issue and its still in discussion.

The feature works only starting Chrome 56, with some intermediary changes (such as sticky on thead's) until Chrome 58. On Firefox, position: sticky exists and works, but as position: relative, its behavior on table elements is undefined. You can read about that in the MDN for CSS position as well as this bug. There has been no news on this end for the past months, however.

To summarize, use position: sticky if you want effortless and fast sticky table headers that keep in sync with the widths of their column and can live with the fact that only recent Chrome versions are supported. It does fall back to scrolling headers on other browsers, however.

Minimal example

The following is a minmal example for sticky headers in Chrome. Link to jsfiddle.

Character Position
A1
B2
C3
D4
E5
F6
G7
H8
I9
J10
K11
L12
M13
N14
O15
P16
Q17
R18
S19
T20
U21
V22
W23
X24
Y25
Z26
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<style type="text/css">
table.sticky th {
  position: sticky;
  // positional attribute is required for position to take affect.
  top: 2px;
}
</style>


<table class="sticky">
  <thead>
    <th>Character</th>
    <th>Position</th>
  </thead>
  <tbody>
    <tr><td>A</td><td>1</td></tr>
    <tr><td>B</td><td>2</td></tr>
    <tr><td>C</td><td>3</td></tr>
    <tr><td>D</td><td>4</td></tr>
    <tr><td>E</td><td>5</td></tr>
    <tr><td>F</td><td>6</td></tr>
    <tr><td>G</td><td>7</td></tr>
    <tr><td>H</td><td>8</td></tr>
    <tr><td>I</td><td>9</td></tr>
    <tr><td>J</td><td>10</td></tr>
    <tr><td>K</td><td>11</td></tr>
    <tr><td>L</td><td>12</td></tr>
    <tr><td>M</td><td>13</td></tr>
    <tr><td>N</td><td>14</td></tr>
    <tr><td>O</td><td>15</td></tr>
    <tr><td>P</td><td>16</td></tr>
    <tr><td>Q</td><td>17</td></tr>
    <tr><td>R</td><td>18</td></tr>
    <tr><td>S</td><td>19</td></tr>
    <tr><td>T</td><td>20</td></tr>
    <tr><td>U</td><td>21</td></tr>
    <tr><td>V</td><td>22</td></tr>
    <tr><td>W</td><td>23</td></tr>
    <tr><td>X</td><td>24</td></tr>
    <tr><td>Y</td><td>25</td></tr>
    <tr><td>Z</td><td>26</td></tr>
  </tbody>
</table>