[html] HTML table headers always visible at top of window when viewing a large table

I would like to be able to "tweak" an HTML table's presentation to add a single feature: when scrolling down through the page so that the table is on the screen but the header rows are off-screen, I would like the headers to remain visible at the top of the viewing area.

This would be conceptually like the "freeze panes" feature in Excel. However, an HTML page might contain several tables in it and I only would want it to happen for the table that is currently in-view, only while it is in-view.

Note: I've seen one solution where the table data area is made scrollable while the headers do not scroll. That's not the solution I'm looking for.

This question is related to html html-table

The answer is


Possible alternatives

js-floating-table-headers

js-floating-table-headers (Google Code)

In Drupal

I have a Drupal 6 site. I was on the admin "modules" page, and noticed the tables had this exact feature!

Looking at the code, it seems to be implemented by a file called tableheader.js. It applies the feature on all tables with the class sticky-enabled.

For a Drupal site, I'd like to be able to make use of that tableheader.js module as-is for user content. tableheader.js doesn't seem to be present on user content pages in Drupal. I posted a forum message to ask how to modify the Drupal theme so it's available. According to a response, tableheader.js can be added to a Drupal theme using drupal_add_js() in the theme's template.php as follows:

drupal_add_js('misc/tableheader.js', 'core');

If you use a full screen table you are maybe interested in setting th to display:fixed; and top:0; or try a very similar approach via css.

Update

Just quickly build up a working solution with iframes (html4.0). This example IS NOT standard conform, however you will easily be able to fix it:

outer.html

<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">   
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">     
    <head>      
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />   
        <title>Outer</title>
  <body>
    <iframe src="test.html" width="200" height="100"></iframe>
    </body>
</html> 

test.html

<?xml version='1.0' encoding='UTF-8'?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">   
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en">     
    <head>      
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />   
        <title>Floating</title>
    <style type="text/css">
      .content{
        position:relative; 
      }

      thead{
        background-color:red;
        position:fixed; 
        top:0;
      }
    </style>
  <body>
    <div class="content">      
      <table>
        <thead>
          <tr class="top"><td>Title</td></tr>
        </head>
        <tbody>
          <tr><td>a</td></tr>
          <tr><td>b</td></tr>
          <tr><td>c</td></tr>
          <tr><td>d</td></tr>
          <tr><td>e</td></tr>
          <tr><td>e</td></tr>
          <tr><td>e</td></tr>
          <tr><td>e</td></tr>
          <tr><td>e</td></tr>
          <tr><td>e</td></tr>
        </tbody>
      </table>
    </div>
    </body>
</html> 

I've encountered this problem very recently. Unfortunately, I had to do 2 tables, one for the header and one for the body. It's probably not the best approach ever but here goes:

_x000D_
_x000D_
<html>_x000D_
<head>_x000D_
    <title>oh hai</title>_x000D_
</head>_x000D_
<body>_x000D_
    <table id="tableHeader">_x000D_
        <tr>_x000D_
            <th style="width:100px; background-color:#CCCCCC">col header</th>_x000D_
            <th style="width:100px; background-color:#CCCCCC">col header</th>_x000D_
        </tr>_x000D_
    </table>_x000D_
    <div style="height:50px; overflow:auto; width:250px">_x000D_
        <table>_x000D_
            <tr>_x000D_
                <td style="height:50px; width:100px; background-color:#DDDDDD">data1</td>_x000D_
                <td style="height:50px; width:100px; background-color:#DDDDDD">data1</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td style="height:50px; width:100px; background-color:#DDDDDD">data2</td>_x000D_
                <td style="height:50px; width:100px; background-color:#DDDDDD">data2</td>_x000D_
            </tr>_x000D_
        </table>_x000D_
    </div>_x000D_
</body>_x000D_
</html>
_x000D_
_x000D_
_x000D_

This worked for me, it's probably not the elegant way but it does work. I'll investigate so see if I can do something better, but it allows for multiple tables.

Go read on the overflow propriety to see if it fits your need


The most simple answer only using CSS :D !!!

_x000D_
_x000D_
table {_x000D_
  /* Not required only for visualizing */_x000D_
  border-collapse: collapse;_x000D_
  width: 100%;_x000D_
}_x000D_
_x000D_
table thead tr th {_x000D_
  /* you could also change td instead th depending your html code */_x000D_
  background-color: green;_x000D_
  position: sticky;_x000D_
  z-index: 100;_x000D_
  top: 0;_x000D_
}_x000D_
_x000D_
td {_x000D_
  /* Not required only for visualizing */_x000D_
  padding: 1em;_x000D_
}
_x000D_
<table>_x000D_
  <thead>_x000D_
    <tr>_x000D_
      <th>Col1</th>_x000D_
      <th>Col2</th>_x000D_
      <th>Col3</th>_x000D_
    </tr>_x000D_
  </thead>_x000D_
  <tbody>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
       <td>data</td>_x000D_
     </tr>_x000D_
     <tr>_x000D_
       <td>david</td>_x000D_
       <td>castro</td>_x000D_
       <td>rocks!</td>_x000D_
     </tr>_x000D_
  </tbody>_x000D_
</table>
_x000D_
_x000D_
_x000D_


Craig, I refined your code a bit (among a few other things it's now using position:fixed) and wrapped it as a jQuery plugin.

Try it out here: http://jsfiddle.net/jmosbech/stFcx/

And get the source here: https://github.com/jmosbech/StickyTableHeaders


That proof of concept you made was great! However I also found this jQuery plugin which seems to be working very well. Hope it helps!


This is really a tricky thing to have a sticky header on your table. I had same requirement but with asp:GridView and then I found it really thought to have sticky header on gridview. There are many solutions available and it took me 3 days trying all the solution but none of them could satisfy.

The main issue that I faced with most of these solutions was the alignment problem. When you try to make the header floating, somehow the alignment of header cells and body cells get off track.

With some solutions, I also got issue of getting header overlapped to first few rows of body, which cause body rows getting hidden behind the floating header.

So now I had to implement my own logic to achieve this, though I also not consider this as perfect solution but this could also be helpful for someone,

Below is the sample table.

<div class="table-holder">
        <table id="MyTable" cellpadding="4" cellspacing="0" border="1px" class="customerTable">
            <thead>
                <tr><th>ID</th><th>First Name</th><th>Last Name</th><th>DOB</th><th>Place</th></tr>
            </thead>
            <tbody>
                <tr><td>1</td><td>Customer1</td><td>LastName</td><td>1-1-1</td><td>SUN</td></tr>
                <tr><td>2</td><td>Customer2</td><td>LastName</td><td>2-2-2</td><td>Earth</td></tr>
                <tr><td>3</td><td>Customer3</td><td>LastName</td><td>3-3-3</td><td>Mars</td></tr>
                <tr><td>4</td><td>Customer4</td><td>LastName</td><td>4-4-4</td><td>Venus</td></tr>
                <tr><td>5</td><td>Customer5</td><td>LastName</td><td>5-5-5</td><td>Saturn</td></tr>
                <tr><td>6</td><td>Customer6</td><td>LastName</td><td>6-6-6</td><td>Jupitor</td></tr>
                <tr><td>7</td><td>Customer7</td><td>LastName</td><td>7-7-7</td><td>Mercury</td></tr>
                <tr><td>8</td><td>Customer8</td><td>LastName</td><td>8-8-8</td><td>Moon</td></tr>
                <tr><td>9</td><td>Customer9</td><td>LastName</td><td>9-9-9</td><td>Uranus</td></tr>
                <tr><td>10</td><td>Customer10</td><td>LastName</td><td>10-10-10</td><td>Neptune</td></tr>
            </tbody>
        </table>
    </div>

Note: The table is wrapped into a DIV with class attribute equal to 'table-holder'.

Below is the JQuery script that I added in my html page header.

<script src="../Scripts/jquery-1.7.2.min.js" type="text/javascript"></script>
<script src="../Scripts/jquery-ui.min.js" type="text/javascript"></script>
<script type="text/javascript">
    $(document).ready(function () {
        //create var for table holder
        var originalTableHolder = $(".table-holder");
        // set the table holder's with
        originalTableHolder.width($('table', originalTableHolder).width() + 17);
        // Create a clone of table holder DIV
        var clonedtableHolder = originalTableHolder.clone();

        // Calculate height of all header rows.
        var headerHeight = 0;
        $('thead', originalTableHolder).each(function (index, element) {
            headerHeight = headerHeight + $(element).height();
        });

        // Set the position of cloned table so that cloned table overlapped the original
        clonedtableHolder.css('position', 'relative');
        clonedtableHolder.css('top', headerHeight + 'px');

        // Set the height of cloned header equal to header height only so that body is not visible of cloned header
        clonedtableHolder.height(headerHeight);
        clonedtableHolder.css('overflow', 'hidden');

        // reset the ID attribute of each element in cloned table
        $('*', clonedtableHolder).each(function (index, element) {
            if ($(element).attr('id')) {
                $(element).attr('id', $(element).attr('id') + '_Cloned');
            }
        });

        originalTableHolder.css('border-bottom', '1px solid #aaa');

        // Place the cloned table holder before original one
        originalTableHolder.before(clonedtableHolder);
    });
</script>

and at last below is the CSS class for bit of coloring purpose.

.table-holder
{
    height:200px;
    overflow:auto;
    border-width:0px;    
}

.customerTable thead
{
    background: #4b6c9e;        
    color:White;
}

So the whole idea of this logic is to place the table into a table holder div and create clone of that holder at client side when page loaded. Now hide the body of table inside clone holder and position the remaining header part over to original header.

Same solution also works for asp:gridview, you need to add two more steps to achieve this in gridview,

  1. In OnPrerender event of gridview object in your web page, set the table section of header row equal to TableHeader.

    if (this.HeaderRow != null)
    {
        this.HeaderRow.TableSection = TableRowSection.TableHeader;
    }
    
  2. And wrap your grid into <div class="table-holder"></div>.

Note: if your header has clickable controls then you may need to add some more jQuery script to pass the events raised in cloned header to original header. This code is already available in jQuery sticky-header plugin create by jmosbech


I've made a proof-of-concept solution using jQuery.

View sample here.

I've now got this code in a Mercurial bitbucket repository. The main file is tables.html.

I'm aware of one issue with this: if the table contains anchors, and if you open the URL with the specified anchor in a browser, when the page loads, the row with the anchor will probably be obscured by the floating header.

Update 2017-12-11: I see this doesn't work with current Firefox (57) and Chrome (63). Not sure when and why this stopped working, or how to fix it. But now, I think the accepted answer by Hendy Irawan is superior.


It's frustrating that what works great in one browser doesn't work in others. The following works in Firefox, but not in Chrome or IE:

<table width="80%">

 <thead>

 <tr>
  <th>Column 1</th>
  <th>Column 2</th>
  <th>Column 3</th>
 </tr>

 </thead>

 <tbody style="height:50px; overflow:auto">

  <tr>
    <td>Cell A1</td>
    <td>Cell B1</td>
    <td>Cell C1</td>
  </tr>

  <tr>
    <td>Cell A2</td>
    <td>Cell B2</td>
    <td>Cell C2</td>
  </tr>

  <tr>
    <td>Cell A3</td>
    <td>Cell B3</td>
    <td>Cell C3</td>
  </tr>

 </tbody>

</table>

Using display: fixed on the thead section should work, but for it only work on the current table in view, you will need the help of JavaScript. And it will be tricky because it will need to figure out scrolling places and location of elements relative to the viewport, which is one of the prime areas of browser incompatibility.

Have a look at the popular JavaScript frameworks (jQuery, MooTools, YUI, etc etc.) to see if they can either do what you want or make it easier to do what you want.


If you're targeting modern css3 compliant browsers (Browser support: https://caniuse.com/#feat=css-sticky) you can use position:sticky, which doesn't require JS and won't break the table layout miss-aligning th and td of the same column. Nor does it require fixed column width to work properly.

Example for a single header row:

thead th
{
    position: sticky;
    top: 0px;
}

For theads with 1 or 2 rows, you can use something like this:

thead > :last-child th
{
    position: sticky;
    top: 30px; /* This is for all the the "th" elements in the second row, (in this casa is the last child element into the thead) */
}

thead > :first-child th
{
    position: sticky;
    top: 0px; /* This is for all the the "th" elements in the first child row */
}

You might need to play a bit with the top property of the last child changing the number of pixels to match the height of the first row (+ the margin + the border + the padding, if any), so the second row sticks just down bellow the first one.

Also both solutions work even if you have more than one table in the same page: the th element of each one starts to be sticky when its top position is the one indicated into the css definition and just disappear when all the table scrolls down. So if there are more tables all work beautifully the same way.

Why to use last-child before and first-child after in the css?

Because css rules are rendered by the browser in the same order as you write them into the css file and because of this if you have just 1 row into the thead element the first row is simultaneously the last row too and the first-child rule need to override the last-child one. If not you will have an offset of the row 30 px from the top margin which I suppose you don't want to.

A known problem of position: sticky is that it doesn't work on thead elements or table rows: you must target th elements. Hopping this issue will be solved on future browser versions.