[javascript] HTML table with fixed headers?

Is there a cross-browser CSS/JavaScript technique to display a long HTML table such that the column headers stay fixed on-screen and do not scroll with the table body. Think of the "freeze panes" effect in Microsoft Excel.

I want to be able to scroll through the contents of the table, but to always be able to see the column headers at the top.

This question is related to javascript css html-table

The answer is


A lot of people seem to be looking for this answer. I found it buried in an answer to another question here: Syncing column width of between tables in two different frames, etc

Of the dozens of methods I have tried this is the only method I found that works reliably to allow you to have a scrolling bottom table with the header table having the same widths.

Here is how I did it, first I improved upon the jsfiddle above to create this function, which works on both td and th (in case that trips up others who use th for styling of their header rows).

var setHeaderTableWidth= function (headertableid,basetableid) {
            $("#"+headertableid).width($("#"+basetableid).width());
            $("#"+headertableid+" tr th").each(function (i) {
                $(this).width($($("#"+basetableid+" tr:first td")[i]).width());
            });
            $("#" + headertableid + " tr td").each(function (i) {
                $(this).width($($("#" + basetableid + " tr:first td")[i]).width());
            });
        }

Next, you need to create two tables, NOTE the header table should have an extra TD to leave room in the top table for the scrollbar, like this:

 <table id="headertable1" class="input-cells table-striped">
        <thead>
            <tr style="background-color:darkgray;color:white;"><th>header1</th><th>header2</th><th>header3</th><th>header4</th><th>header5</th><th>header6</th><th></th></tr>
        </thead>
     </table>
    <div id="resizeToBottom" style="overflow-y:scroll;overflow-x:hidden;">
        <table id="basetable1" class="input-cells table-striped">
            <tbody >
                <tr>
                    <td>testdata</td>
                    <td>2</td>
                    <td>3</td>
                    <td>4</span></td>
                    <td>55555555555555</td>
                    <td>test</td></tr>
            </tbody>
        </table>
    </div>

Then do something like:

        setHeaderTableWidth('headertable1', 'basetable1');
        $(window).resize(function () {
            setHeaderTableWidth('headertable1', 'basetable1');
        });

This is the only solution that I found on Stack Overflow that works out of many similar questions that have been posted, that works in all my cases.

For example, I tried the jQuery stickytables plugin which does not work with durandal, and the Google Code project here https://code.google.com/p/js-scroll-table-header/issues/detail?id=2

Other solutions involving cloning the tables, have poor performance, or suck and don't work in all cases.

There is no need for these overly complex solutions. Just make two tables like the examples below and call setHeaderTableWidth function like described here and boom, you are done.

If this does not work for you, you probably were playing with your CSS box-sizing property and you need to set it correctly. It is easy to screw up your CSS content by accident. There are many things that can go wrong, so just be aware/careful of that. This approach works for me.


By applying the StickyTableHeaders jQuery plugin to the table, the column headers will stick to the top of the viewport as you scroll down.

Example:

_x000D_
_x000D_
$(function () {_x000D_
    $("table").stickyTableHeaders();_x000D_
});_x000D_
_x000D_
/*! Copyright (c) 2011 by Jonas Mosbech - https://github.com/jmosbech/StickyTableHeaders_x000D_
 MIT license info: https://github.com/jmosbech/StickyTableHeaders/blob/master/license.txt */_x000D_
_x000D_
;_x000D_
(function ($, window, undefined) {_x000D_
    'use strict';_x000D_
_x000D_
    var name = 'stickyTableHeaders',_x000D_
        id = 0,_x000D_
        defaults = {_x000D_
            fixedOffset: 0,_x000D_
            leftOffset: 0,_x000D_
            marginTop: 0,_x000D_
            scrollableArea: window_x000D_
        };_x000D_
_x000D_
    function Plugin(el, options) {_x000D_
        // To avoid scope issues, use 'base' instead of 'this'_x000D_
        // to reference this class from internal events and functions._x000D_
        var base = this;_x000D_
_x000D_
        // Access to jQuery and DOM versions of element_x000D_
        base.$el = $(el);_x000D_
        base.el = el;_x000D_
        base.id = id++;_x000D_
        base.$window = $(window);_x000D_
        base.$document = $(document);_x000D_
_x000D_
        // Listen for destroyed, call teardown_x000D_
        base.$el.bind('destroyed',_x000D_
        $.proxy(base.teardown, base));_x000D_
_x000D_
        // Cache DOM refs for performance reasons_x000D_
        base.$clonedHeader = null;_x000D_
        base.$originalHeader = null;_x000D_
_x000D_
        // Keep track of state_x000D_
        base.isSticky = false;_x000D_
        base.hasBeenSticky = false;_x000D_
        base.leftOffset = null;_x000D_
        base.topOffset = null;_x000D_
_x000D_
        base.init = function () {_x000D_
            base.$el.each(function () {_x000D_
                var $this = $(this);_x000D_
_x000D_
                // remove padding on <table> to fix issue #7_x000D_
                $this.css('padding', 0);_x000D_
_x000D_
                base.$originalHeader = $('thead:first', this);_x000D_
                base.$clonedHeader = base.$originalHeader.clone();_x000D_
                $this.trigger('clonedHeader.' + name, [base.$clonedHeader]);_x000D_
_x000D_
                base.$clonedHeader.addClass('tableFloatingHeader');_x000D_
                base.$clonedHeader.css('display', 'none');_x000D_
_x000D_
                base.$originalHeader.addClass('tableFloatingHeaderOriginal');_x000D_
_x000D_
                base.$originalHeader.after(base.$clonedHeader);_x000D_
_x000D_
                base.$printStyle = $('<style type="text/css" media="print">' +_x000D_
                    '.tableFloatingHeader{display:none !important;}' +_x000D_
                    '.tableFloatingHeaderOriginal{position:static !important;}' +_x000D_
                    '</style>');_x000D_
                $('head').append(base.$printStyle);_x000D_
            });_x000D_
_x000D_
            base.setOptions(options);_x000D_
            base.updateWidth();_x000D_
            base.toggleHeaders();_x000D_
            base.bind();_x000D_
        };_x000D_
_x000D_
        base.destroy = function () {_x000D_
            base.$el.unbind('destroyed', base.teardown);_x000D_
            base.teardown();_x000D_
        };_x000D_
_x000D_
        base.teardown = function () {_x000D_
            if (base.isSticky) {_x000D_
                base.$originalHeader.css('position', 'static');_x000D_
            }_x000D_
            $.removeData(base.el, 'plugin_' + name);_x000D_
            base.unbind();_x000D_
_x000D_
            base.$clonedHeader.remove();_x000D_
            base.$originalHeader.removeClass('tableFloatingHeaderOriginal');_x000D_
            base.$originalHeader.css('visibility', 'visible');_x000D_
            base.$printStyle.remove();_x000D_
_x000D_
            base.el = null;_x000D_
            base.$el = null;_x000D_
        };_x000D_
_x000D_
        base.bind = function () {_x000D_
            base.$scrollableArea.on('scroll.' + name, base.toggleHeaders);_x000D_
            if (!base.isWindowScrolling) {_x000D_
                base.$window.on('scroll.' + name + base.id, base.setPositionValues);_x000D_
                base.$window.on('resize.' + name + base.id, base.toggleHeaders);_x000D_
            }_x000D_
            base.$scrollableArea.on('resize.' + name, base.toggleHeaders);_x000D_
            base.$scrollableArea.on('resize.' + name, base.updateWidth);_x000D_
        };_x000D_
_x000D_
        base.unbind = function () {_x000D_
            // unbind window events by specifying handle so we don't remove too much_x000D_
            base.$scrollableArea.off('.' + name, base.toggleHeaders);_x000D_
            if (!base.isWindowScrolling) {_x000D_
                base.$window.off('.' + name + base.id, base.setPositionValues);_x000D_
                base.$window.off('.' + name + base.id, base.toggleHeaders);_x000D_
            }_x000D_
            base.$scrollableArea.off('.' + name, base.updateWidth);_x000D_
        };_x000D_
_x000D_
        base.toggleHeaders = function () {_x000D_
            if (base.$el) {_x000D_
                base.$el.each(function () {_x000D_
                    var $this = $(this),_x000D_
                        newLeft,_x000D_
                        newTopOffset = base.isWindowScrolling ? (_x000D_
                        isNaN(base.options.fixedOffset) ? base.options.fixedOffset.outerHeight() : base.options.fixedOffset) : base.$scrollableArea.offset().top + (!isNaN(base.options.fixedOffset) ? base.options.fixedOffset : 0),_x000D_
                        offset = $this.offset(),_x000D_
_x000D_
                        scrollTop = base.$scrollableArea.scrollTop() + newTopOffset,_x000D_
                        scrollLeft = base.$scrollableArea.scrollLeft(),_x000D_
_x000D_
                        scrolledPastTop = base.isWindowScrolling ? scrollTop > offset.top : newTopOffset > offset.top,_x000D_
                        notScrolledPastBottom = (base.isWindowScrolling ? scrollTop : 0) < (offset.top + $this.height() - base.$clonedHeader.height() - (base.isWindowScrolling ? 0 : newTopOffset));_x000D_
_x000D_
                    if (scrolledPastTop && notScrolledPastBottom) {_x000D_
                        newLeft = offset.left - scrollLeft + base.options.leftOffset;_x000D_
                        base.$originalHeader.css({_x000D_
                            'position': 'fixed',_x000D_
                                'margin-top': base.options.marginTop,_x000D_
                                'left': newLeft,_x000D_
                                'z-index': 3 // #18: opacity bug_x000D_
                        });_x000D_
                        base.leftOffset = newLeft;_x000D_
                        base.topOffset = newTopOffset;_x000D_
                        base.$clonedHeader.css('display', '');_x000D_
                        if (!base.isSticky) {_x000D_
                            base.isSticky = true;_x000D_
                            // make sure the width is correct: the user might have resized the browser while in static mode_x000D_
                            base.updateWidth();_x000D_
                        }_x000D_
                        base.setPositionValues();_x000D_
                    } else if (base.isSticky) {_x000D_
                        base.$originalHeader.css('position', 'static');_x000D_
                        base.$clonedHeader.css('display', 'none');_x000D_
                        base.isSticky = false;_x000D_
                        base.resetWidth($('td,th', base.$clonedHeader), $('td,th', base.$originalHeader));_x000D_
                    }_x000D_
                });_x000D_
            }_x000D_
        };_x000D_
_x000D_
        base.setPositionValues = function () {_x000D_
            var winScrollTop = base.$window.scrollTop(),_x000D_
                winScrollLeft = base.$window.scrollLeft();_x000D_
            if (!base.isSticky || winScrollTop < 0 || winScrollTop + base.$window.height() > base.$document.height() || winScrollLeft < 0 || winScrollLeft + base.$window.width() > base.$document.width()) {_x000D_
                return;_x000D_
            }_x000D_
            base.$originalHeader.css({_x000D_
                'top': base.topOffset - (base.isWindowScrolling ? 0 : winScrollTop),_x000D_
                    'left': base.leftOffset - (base.isWindowScrolling ? 0 : winScrollLeft)_x000D_
            });_x000D_
        };_x000D_
_x000D_
        base.updateWidth = function () {_x000D_
            if (!base.isSticky) {_x000D_
                return;_x000D_
            }_x000D_
            // Copy cell widths from clone_x000D_
            if (!base.$originalHeaderCells) {_x000D_
                base.$originalHeaderCells = $('th,td', base.$originalHeader);_x000D_
            }_x000D_
            if (!base.$clonedHeaderCells) {_x000D_
                base.$clonedHeaderCells = $('th,td', base.$clonedHeader);_x000D_
            }_x000D_
            var cellWidths = base.getWidth(base.$clonedHeaderCells);_x000D_
            base.setWidth(cellWidths, base.$clonedHeaderCells, base.$originalHeaderCells);_x000D_
_x000D_
            // Copy row width from whole table_x000D_
            base.$originalHeader.css('width', base.$clonedHeader.width());_x000D_
        };_x000D_
_x000D_
        base.getWidth = function ($clonedHeaders) {_x000D_
            var widths = [];_x000D_
            $clonedHeaders.each(function (index) {_x000D_
                var width, $this = $(this);_x000D_
_x000D_
                if ($this.css('box-sizing') === 'border-box') {_x000D_
                    width = $this[0].getBoundingClientRect().width; // #39: border-box bug_x000D_
                } else {_x000D_
                    var $origTh = $('th', base.$originalHeader);_x000D_
                    if ($origTh.css('border-collapse') === 'collapse') {_x000D_
                        if (window.getComputedStyle) {_x000D_
                            width = parseFloat(window.getComputedStyle(this, null).width);_x000D_
                        } else {_x000D_
                            // ie8 only_x000D_
                            var leftPadding = parseFloat($this.css('padding-left'));_x000D_
                            var rightPadding = parseFloat($this.css('padding-right'));_x000D_
                            // Needs more investigation - this is assuming constant border around this cell and it's neighbours._x000D_
                            var border = parseFloat($this.css('border-width'));_x000D_
                            width = $this.outerWidth() - leftPadding - rightPadding - border;_x000D_
                        }_x000D_
                    } else {_x000D_
                        width = $this.width();_x000D_
                    }_x000D_
                }_x000D_
_x000D_
                widths[index] = width;_x000D_
            });_x000D_
            return widths;_x000D_
        };_x000D_
_x000D_
        base.setWidth = function (widths, $clonedHeaders, $origHeaders) {_x000D_
            $clonedHeaders.each(function (index) {_x000D_
                var width = widths[index];_x000D_
                $origHeaders.eq(index).css({_x000D_
                    'min-width': width,_x000D_
                        'max-width': width_x000D_
                });_x000D_
            });_x000D_
        };_x000D_
_x000D_
        base.resetWidth = function ($clonedHeaders, $origHeaders) {_x000D_
            $clonedHeaders.each(function (index) {_x000D_
                var $this = $(this);_x000D_
                $origHeaders.eq(index).css({_x000D_
                    'min-width': $this.css('min-width'),_x000D_
                        'max-width': $this.css('max-width')_x000D_
                });_x000D_
            });_x000D_
        };_x000D_
_x000D_
        base.setOptions = function (options) {_x000D_
            base.options = $.extend({}, defaults, options);_x000D_
            base.$scrollableArea = $(base.options.scrollableArea);_x000D_
            base.isWindowScrolling = base.$scrollableArea[0] === window;_x000D_
        };_x000D_
_x000D_
        base.updateOptions = function (options) {_x000D_
            base.setOptions(options);_x000D_
            // scrollableArea might have changed_x000D_
            base.unbind();_x000D_
            base.bind();_x000D_
            base.updateWidth();_x000D_
            base.toggleHeaders();_x000D_
        };_x000D_
_x000D_
        // Run initializer_x000D_
        base.init();_x000D_
    }_x000D_
_x000D_
    // A plugin wrapper around the constructor,_x000D_
    // preventing against multiple instantiations_x000D_
    $.fn[name] = function (options) {_x000D_
        return this.each(function () {_x000D_
            var instance = $.data(this, 'plugin_' + name);_x000D_
            if (instance) {_x000D_
                if (typeof options === 'string') {_x000D_
                    instance[options].apply(instance);_x000D_
                } else {_x000D_
                    instance.updateOptions(options);_x000D_
                }_x000D_
            } else if (options !== 'destroy') {_x000D_
                $.data(this, 'plugin_' + name, new Plugin(this, options));_x000D_
            }_x000D_
        });_x000D_
    };_x000D_
_x000D_
})(jQuery, window);
_x000D_
body {_x000D_
    margin: 0 auto;_x000D_
    padding: 0 20px;_x000D_
    font-family: Arial, Helvetica, sans-serif;_x000D_
    font-size: 11px;_x000D_
    color: #555;_x000D_
}_x000D_
table {_x000D_
    border: 0;_x000D_
    padding: 0;_x000D_
    margin: 0 0 20px 0;_x000D_
    border-collapse: collapse;_x000D_
}_x000D_
th {_x000D_
    padding: 5px;_x000D_
    /* NOTE: th padding must be set explicitly in order to support IE */_x000D_
    text-align: right;_x000D_
    font-weight:bold;_x000D_
    line-height: 2em;_x000D_
    color: #FFF;_x000D_
    background-color: #555;_x000D_
}_x000D_
tbody td {_x000D_
    padding: 10px;_x000D_
    line-height: 18px;_x000D_
    border-top: 1px solid #E0E0E0;_x000D_
}_x000D_
tbody tr:nth-child(2n) {_x000D_
    background-color: #F7F7F7;_x000D_
}_x000D_
tbody tr:hover {_x000D_
    background-color: #EEEEEE;_x000D_
}_x000D_
td {_x000D_
    text-align: right;_x000D_
}_x000D_
td:first-child, th:first-child {_x000D_
    text-align: left;_x000D_
}
_x000D_
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.9.1/jquery.min.js"></script>_x000D_
<div style="width:3000px">some really really wide content goes here</div>_x000D_
<table>_x000D_
    <thead>_x000D_
        <tr>_x000D_
            <th colspan="9">Companies listed on NASDAQ OMX Copenhagen.</th>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <th>Full name</th>_x000D_
            <th>CCY</th>_x000D_
            <th>Last</th>_x000D_
            <th>+/-</th>_x000D_
            <th>%</th>_x000D_
            <th>Bid</th>_x000D_
            <th>Ask</th>_x000D_
            <th>Volume</th>_x000D_
            <th>Turnover</th>_x000D_
        </tr>_x000D_
    </thead>_x000D_
    <tbody>_x000D_
        <tr>_x000D_
            <td>A.P. Møller...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>33,220.00</td>_x000D_
            <td>760</td>_x000D_
            <td>2.34</td>_x000D_
            <td>33,140.00</td>_x000D_
            <td>33,220.00</td>_x000D_
            <td>594</td>_x000D_
            <td>19,791,910</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>A.P. Møller...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>34,620.00</td>_x000D_
            <td>640</td>_x000D_
            <td>1.88</td>_x000D_
            <td>34,620.00</td>_x000D_
            <td>34,700.00</td>_x000D_
            <td>9,954</td>_x000D_
            <td>346,530,246</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Carlsberg A</td>_x000D_
            <td>DKK</td>_x000D_
            <td>380</td>_x000D_
            <td>0</td>_x000D_
            <td>0</td>_x000D_
            <td>371</td>_x000D_
            <td>391.5</td>_x000D_
            <td>6</td>_x000D_
            <td>2,280</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Carlsberg B</td>_x000D_
            <td>DKK</td>_x000D_
            <td>364.4</td>_x000D_
            <td>8.6</td>_x000D_
            <td>2.42</td>_x000D_
            <td>363</td>_x000D_
            <td>364.4</td>_x000D_
            <td>636,267</td>_x000D_
            <td>228,530,601</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Chr. Hansen...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>114.5</td>_x000D_
            <td>-1.6</td>_x000D_
            <td>-1.38</td>_x000D_
            <td>114.2</td>_x000D_
            <td>114.5</td>_x000D_
            <td>141,822</td>_x000D_
            <td>16,311,454</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Coloplast B</td>_x000D_
            <td>DKK</td>_x000D_
            <td>809.5</td>_x000D_
            <td>11</td>_x000D_
            <td>1.38</td>_x000D_
            <td>809</td>_x000D_
            <td>809.5</td>_x000D_
            <td>85,840</td>_x000D_
            <td>69,363,301</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>D/S Norden</td>_x000D_
            <td>DKK</td>_x000D_
            <td>155</td>_x000D_
            <td>-1.5</td>_x000D_
            <td>-0.96</td>_x000D_
            <td>155</td>_x000D_
            <td>155.1</td>_x000D_
            <td>51,681</td>_x000D_
            <td>8,037,225</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Danske Bank</td>_x000D_
            <td>DKK</td>_x000D_
            <td>69.05</td>_x000D_
            <td>2.55</td>_x000D_
            <td>3.83</td>_x000D_
            <td>69.05</td>_x000D_
            <td>69.2</td>_x000D_
            <td>1,723,719</td>_x000D_
            <td>115,348,068</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>DSV</td>_x000D_
            <td>DKK</td>_x000D_
            <td>105.4</td>_x000D_
            <td>0.2</td>_x000D_
            <td>0.19</td>_x000D_
            <td>105.2</td>_x000D_
            <td>105.4</td>_x000D_
            <td>674,873</td>_x000D_
            <td>71,575,035</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>FLSmidth &amp; Co.</td>_x000D_
            <td>DKK</td>_x000D_
            <td>295.8</td>_x000D_
            <td>-1.8</td>_x000D_
            <td>-0.6</td>_x000D_
            <td>295.1</td>_x000D_
            <td>295.8</td>_x000D_
            <td>341,263</td>_x000D_
            <td>100,301,032</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>G4S plc</td>_x000D_
            <td>DKK</td>_x000D_
            <td>22.53</td>_x000D_
            <td>0.05</td>_x000D_
            <td>0.22</td>_x000D_
            <td>22.53</td>_x000D_
            <td>22.57</td>_x000D_
            <td>190,920</td>_x000D_
            <td>4,338,150</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Jyske Bank</td>_x000D_
            <td>DKK</td>_x000D_
            <td>144.2</td>_x000D_
            <td>1.4</td>_x000D_
            <td>0.98</td>_x000D_
            <td>142.8</td>_x000D_
            <td>144.2</td>_x000D_
            <td>78,163</td>_x000D_
            <td>11,104,874</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Københavns ...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>1,580.00</td>_x000D_
            <td>-12</td>_x000D_
            <td>-0.75</td>_x000D_
            <td>1,590.00</td>_x000D_
            <td>1,620.00</td>_x000D_
            <td>82</td>_x000D_
            <td>131,110</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Lundbeck</td>_x000D_
            <td>DKK</td>_x000D_
            <td>103.4</td>_x000D_
            <td>-2.5</td>_x000D_
            <td>-2.36</td>_x000D_
            <td>103.4</td>_x000D_
            <td>103.8</td>_x000D_
            <td>157,162</td>_x000D_
            <td>16,462,282</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Nordea Bank</td>_x000D_
            <td>DKK</td>_x000D_
            <td>43.22</td>_x000D_
            <td>-0.06</td>_x000D_
            <td>-0.14</td>_x000D_
            <td>43.22</td>_x000D_
            <td>43.25</td>_x000D_
            <td>167,520</td>_x000D_
            <td>7,310,143</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Novo Nordisk B</td>_x000D_
            <td>DKK</td>_x000D_
            <td>552.5</td>_x000D_
            <td>-3.5</td>_x000D_
            <td>-0.63</td>_x000D_
            <td>550.5</td>_x000D_
            <td>552.5</td>_x000D_
            <td>843,533</td>_x000D_
            <td>463,962,375</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Novozymes B</td>_x000D_
            <td>DKK</td>_x000D_
            <td>805.5</td>_x000D_
            <td>5.5</td>_x000D_
            <td>0.69</td>_x000D_
            <td>805</td>_x000D_
            <td>805.5</td>_x000D_
            <td>152,188</td>_x000D_
            <td>121,746,199</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Pandora</td>_x000D_
            <td>DKK</td>_x000D_
            <td>39.04</td>_x000D_
            <td>0.94</td>_x000D_
            <td>2.47</td>_x000D_
            <td>38.8</td>_x000D_
            <td>39.04</td>_x000D_
            <td>350,965</td>_x000D_
            <td>13,611,838</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Rockwool In...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>492</td>_x000D_
            <td>0</td>_x000D_
            <td>0</td>_x000D_
            <td>482</td>_x000D_
            <td>492</td>_x000D_
            <td></td>_x000D_
            <td></td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Rockwool In...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>468</td>_x000D_
            <td>12</td>_x000D_
            <td>2.63</td>_x000D_
            <td>465.2</td>_x000D_
            <td>468</td>_x000D_
            <td>9,885</td>_x000D_
            <td>4,623,850</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Sydbank</td>_x000D_
            <td>DKK</td>_x000D_
            <td>95</td>_x000D_
            <td>0.05</td>_x000D_
            <td>0.05</td>_x000D_
            <td>94.7</td>_x000D_
            <td>95</td>_x000D_
            <td>103,438</td>_x000D_
            <td>9,802,899</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>TDC</td>_x000D_
            <td>DKK</td>_x000D_
            <td>43.6</td>_x000D_
            <td>0.13</td>_x000D_
            <td>0.3</td>_x000D_
            <td>43.5</td>_x000D_
            <td>43.6</td>_x000D_
            <td>845,110</td>_x000D_
            <td>36,785,339</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Topdanmark</td>_x000D_
            <td>DKK</td>_x000D_
            <td>854</td>_x000D_
            <td>13.5</td>_x000D_
            <td>1.61</td>_x000D_
            <td>854</td>_x000D_
            <td>855</td>_x000D_
            <td>38,679</td>_x000D_
            <td>32,737,678</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Tryg</td>_x000D_
            <td>DKK</td>_x000D_
            <td>290.4</td>_x000D_
            <td>0.3</td>_x000D_
            <td>0.1</td>_x000D_
            <td>290</td>_x000D_
            <td>290.4</td>_x000D_
            <td>94,587</td>_x000D_
            <td>27,537,247</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>Vestas Wind...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>90.15</td>_x000D_
            <td>-4.2</td>_x000D_
            <td>-4.45</td>_x000D_
            <td>90.1</td>_x000D_
            <td>90.15</td>_x000D_
            <td>1,317,313</td>_x000D_
            <td>121,064,314</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
            <td>William Dem...</td>_x000D_
            <td>DKK</td>_x000D_
            <td>417.6</td>_x000D_
            <td>0.1</td>_x000D_
            <td>0.02</td>_x000D_
            <td>417</td>_x000D_
            <td>417.6</td>_x000D_
            <td>64,242</td>_x000D_
            <td>26,859,554</td>_x000D_
        </tr>_x000D_
    </tbody>_x000D_
</table>_x000D_
<div style="height: 4000px">lots of content down here...</div>
_x000D_
_x000D_
_x000D_


This is not an exact solution to the fixed header row, but I have created a rather ingenious method of repeating the header row throughout the long table, yet still keeping the ability to sort.

This neat little option requires the jQuery tablesorter plugin. Here's how it works:

HTML

<table class="tablesorter boxlist" id="pmtable">
    <thead class="fixedheader">
        <tr class="boxheadrow">
            <th width="70px" class="header">Job Number</th>
            <th width="10px" class="header">Pri</th>
            <th width="70px" class="header">CLLI</th>
            <th width="35px" class="header">Market</th>
            <th width="35px" class="header">Job Status</th>
            <th width="65px" class="header">Technology</th>
            <th width="95px;" class="header headerSortDown">MEI</th>
            <th width="95px" class="header">TEO Writer</th>
            <th width="75px" class="header">Quote Due</th>
            <th width="100px" class="header">Engineer</th>
            <th width="75px" class="header">ML Due</th>
            <th width="75px" class="header">ML Complete</th>
            <th width="75px" class="header">SPEC Due</th>
            <th width="75px" class="header">SPEC Complete</th>
            <th width="100px" class="header">Install Supervisor</th>
            <th width="75px" class="header">MasTec OJD</th>
            <th width="75px" class="header">Install Start</th>
            <th width="30px" class="header">Install Hours</th>
            <th width="75px" class="header">Revised CRCD</th>
            <th width="75px" class="header">Latest Ship-To-Site</th>
            <th width="30px" class="header">Total Parts</th>
            <th width="30px" class="header">OEM Rcvd</th>
            <th width="30px" class="header">Minor Rcvd</th>
            <th width="30px" class="header">Total Received</th>
            <th width="30px" class="header">% On Site</th>
            <th width="60px" class="header">Actions</th>
        </tr>
    </thead>
        <tbody class="scrollable">
            <tr data-job_id="3548" data-ml_id="" class="odd">
                <td class="c black">FL-8-RG9UP</td>
                <td data-pri="2" class="priority c yellow">M</td>
                <td class="c">FTLDFLOV</td>
                <td class="c">SFL</td>
                <td class="c">NOI</td>
                <td class="c">TRANSPORT</td>
                <td class="c"></td>
                <td class="c">Chris Byrd</td>
                <td class="c">Apr 13, 2013</td>
                <td class="c">Kris Hall</td>
                <td class="c">May 20, 2013</td>
                <td class="c">May 20, 2013</td>
                <td class="c">Jun 5, 2013</td>
                <td class="c">Jun 7, 2013</td>
                <td class="c">Joseph Fitz</td>
                <td class="c">Jun 10, 2013</td>
                <td class="c">TBD</td>
                <td class="c">123</td>
                <td class="c revised_crcd"><input readonly="true" name="revised_crcd" value="Jul 26, 2013" type="text" size="12" class="smInput r_crcd c hasDatepicker" id="dp1377194058616"></td>
                <td class="c">TBD</td>
                <td class="c">N/A</td>
                <td class="c">N/A</td>
                <td class="c">N/A</td>
                <td class="c">N/A</td>
                <td class="c">N/A</td>
                <td class="actions"><span style="float:left;" class="ui-icon ui-icon-folder-open editJob" title="View this job" s="" details'=""></span></td>
            </tr>
            <tr data-job_id="4264" data-ml_id="2959" class="even">
                <td class="c black">MTS13009SF</td>
                <td data-pri="2" class="priority c yellow">M</td>
                <td class="c">OJUSFLTL</td>
                <td class="c">SFL</td>
                <td class="c">NOI</td>
                <td class="c">TRANSPORT</td>
                <td class="c"></td>
                <td class="c">DeMarcus Stewart</td>
                <td class="c">May 22, 2013</td>
                <td class="c">Ryan Alsobrook</td>
                <td class="c">Jun 19, 2013</td>
                <td class="c">Jun 27, 2013</td>
                <td class="c">Jun 19, 2013</td>
                <td class="c">Jul 4, 2013</td>
                <td class="c">Randy Williams</td>
                <td class="c">Jun 21, 2013</td>
                <td class="c">TBD</td>
                <td class="c">95</td>
                <td class="c revised_crcd"><input readonly="true" name="revised_crcd" value="Aug 9, 2013" type="text" size="12" class="smInput r_crcd c hasDatepicker" id="dp1377194058632"></td><td class="c">TBD</td>
                <td class="c">0</td>
                <td class="c">0.00%</td>
                <td class="c">0.00%</td>
                <td class="c">0.00%</td>
                <td class="c">0.00%</td>
                <td class="actions"><span style="float:left;" class="ui-icon ui-icon-folder-open editJob" title="View this job" s="" details'=""></span><input style="float:left;" type="hidden" name="req_ship" class="reqShip hasDatepicker" id="dp1377194058464"><span style="float:left;" class="ui-icon ui-icon-calendar requestShip" title="Schedule this job for shipping"></span><span class="ui-icon ui-icon-info viewOrderInfo" style="float:left;" title="Show material details for this order"></span></td>
            </tr>
            .
            .
            .
            .
            <tr class="boxheadrow repeated-header">
                <th width="70px" class="header">Job Number</th>
                <th width="10px" class="header">Pri</th>
                <th width="70px" class="header">CLLI</th>
                <th width="35px" class="header">Market</th>
                <th width="35px" class="header">Job Status</th>
                <th width="65px" class="header">Technology</th>
                <th width="95px;" class="header">MEI</th>
                <th width="95px" class="header">TEO Writer</th>
                <th width="75px" class="header">Quote Due</th>
                <th width="100px" class="header">Engineer</th>
                <th width="75px" class="header">ML Due</th>
                <th width="75px" class="header">ML Complete</th>
                <th width="75px" class="header">SPEC Due</th>
                <th width="75px" class="header">SPEC Complete</th>
                <th width="100px" class="header">Install Supervisor</th>
                <th width="75px" class="header">MasTec OJD</th>
                <th width="75px" class="header">Install Start</th>
                <th width="30px" class="header">Install Hours</th>
                <th width="75px" class="header">Revised CRCD</th>
                <th width="75px" class="header">Latest Ship-To-Site</th>
                <th width="30px" class="header">Total Parts</th>
                <th width="30px" class="header">OEM Rcvd</th>
                <th width="30px" class="header">Minor Rcvd</th>
                <th width="30px" class="header">Total Received</th>
                <th width="30px" class="header">% On Site</th>
                <th width="60px" class="header">Actions</th>
            </tr>

Obviously, my table has many more rows than this. 193 to be exact, but you can see where the header row repeats. The repeating header row is set up by this function:

jQuery

// Clone the original header row and add the "repeated-header" class
var tblHeader = $('tr.boxheadrow').clone().addClass('repeated-header');

// Add the cloned header with the new class every 34th row (or as you see fit)
$('tbody tr:odd:nth-of-type(17n)').after(tblHeader);

// On the 'sortStart' routine, remove all the inserted header rows
$('#pmtable').bind('sortStart', function() {
    $('.repeated-header').remove();
    // On the 'sortEnd' routine, add back all the header row lines.
}).bind('sortEnd', function() {
    $('tbody tr:odd:nth-of-type(17n)').after(tblHeader);
});

I found this workaround - move header row in a table above table with data:

_x000D_
_x000D_
<html>_x000D_
<head>_x000D_
 <title>Fixed header</title>_x000D_
 <style>_x000D_
  table td {width:75px;}_x000D_
 </style>_x000D_
</head>_x000D_
_x000D_
<body>_x000D_
<div style="height:auto; width:350px; overflow:auto">_x000D_
<table border="1">_x000D_
<tr>_x000D_
 <td>header 1</td>_x000D_
 <td>header 2</td>_x000D_
 <td>header 3</td>_x000D_
</tr>_x000D_
</table>_x000D_
</div>_x000D_
_x000D_
<div style="height:50px; width:350px; overflow:auto">_x000D_
<table border="1">_x000D_
<tr>_x000D_
 <td>row 1 col 1</td>_x000D_
 <td>row 1 col 2</td>_x000D_
 <td>row 1 col 3</td>  _x000D_
</tr>_x000D_
<tr>_x000D_
 <td>row 2 col 1</td>_x000D_
 <td>row 2 col 2</td>_x000D_
 <td>row 2 col 3</td>  _x000D_
</tr>_x000D_
<tr>_x000D_
 <td>row 3 col 1</td>_x000D_
 <td>row 3 col 2</td>_x000D_
 <td>row 3 col 3</td>  _x000D_
</tr>_x000D_
<tr>_x000D_
 <td>row 4 col 1</td>_x000D_
 <td>row 4 col 2</td>_x000D_
 <td>row 4 col 3</td>  _x000D_
</tr>_x000D_
<tr>_x000D_
 <td>row 5 col 1</td>_x000D_
 <td>row 5 col 2</td>_x000D_
 <td>row 5 col 3</td>  _x000D_
</tr>_x000D_
<tr>_x000D_
 <td>row 6 col 1</td>_x000D_
 <td>row 6 col 2</td>_x000D_
 <td>row 6 col 3</td>  _x000D_
</tr>_x000D_
</table>_x000D_
</div>_x000D_
_x000D_
_x000D_
</body>_x000D_
</html>
_x000D_
_x000D_
_x000D_


Support for fixed footer

I extended Nathan's function to also support a fixed footer and maximum height. Also, the function will set the CSS itself, and you only have to support a width.

Usage:

Fixed height:

$('table').scrollableTable({ height: 100 });

Maximum height (if the browser supports the CSS 'max-height' option):

$('table').scrollableTable({ maxHeight: 100 });

Script:

jQuery.fn.scrollableTable = function(options) {

    var $originalTable, $headTable, $bodyTable, $footTable, $scrollableDiv, originalWidths;

    // Prepare the separate parts of the table
    $originalTable = $(this);
    $headTable = $originalTable.clone();

    $headTable.find('tbody').remove();
    $headTable.find('tfoot').remove();

    $bodyTable = $originalTable.clone();
    $bodyTable.find('thead').remove();
    $bodyTable.find('tfoot').remove();

    $footTable = $originalTable.clone();
    $footTable.find('thead').remove();
    $footTable.find('tbody').remove();

    // Grab the original column widths and set them in the separate tables
    originalWidths = $originalTable.find('tr:first td').map(function() {
        return $(this).width();
    });

    $.each([$headTable, $bodyTable, $footTable], function(index, $table) {
        $table.find('tr:first td').each(function(i) {
            $(this).width(originalWidths[i]);
        });
    });

    // The div that makes the body table scroll
    $scrollableDiv = $('<div/>').css({
        'overflow-y': 'scroll'
    });

    if(options.height) {
        $scrollableDiv.css({'height': options.height});
    }
    else if(options.maxHeight) {
        $scrollableDiv.css({'max-height': options.maxHeight});
    }

    // Add the new separate tables and remove the original one
    $headTable.insertAfter($originalTable);
    $bodyTable.insertAfter($headTable);
    $footTable.insertAfter($bodyTable);
    $bodyTable.wrap($scrollableDiv);
    $originalTable.remove();
};

Here is an improved answer to the one posted by Maximilian Hils.

This one works in Internet Explorer 11 with no flickering whatsoever:

var headerCells = tableWrap.querySelectorAll("thead td");
for (var i = 0; i < headerCells.length; i++) {
    var headerCell = headerCells[i];
    headerCell.style.backgroundColor = "silver";
}
var lastSTop = tableWrap.scrollTop;
tableWrap.addEventListener("scroll", function () {
    var stop = this.scrollTop;
    if (stop < lastSTop) {
        // Resetting the transform for the scrolling up to hide the headers
        for (var i = 0; i < headerCells.length; i++) {
            headerCells[i].style.transitionDelay = "0s";
            headerCells[i].style.transform = "";
        }
    }
    lastSTop = stop;
    var translate = "translate(0," + stop + "px)";
    for (var i = 0; i < headerCells.length; i++) {
        headerCells[i].style.transitionDelay = "0.25s";
        headerCells[i].style.transform = translate;
    }
});

I've just completed putting together a jQuery plugin that will take valid single table using valid HTML (have to have a thead and tbody) and will output a table that has fixed headers, optional fixed footer that can either be a cloned header or any content you chose (pagination, etc.). If you want to take advantage of larger monitors it will also resize the table when the browser is resized. Another added feature is being able to side scroll if the table columns can not all fit in view.

http://fixedheadertable.com/

on github: http://markmalek.github.com/Fixed-Header-Table/

It's extremely easy to setup and you can create your own custom styles for it. It also uses rounded corners in all browsers. Keep in mind I just released it, so it's still technically beta and there are very few minor issues I'm ironing out.

It works in Internet Explorer 7, Internet Explorer 8, Safari, Firefox and Chrome.


I also created a plugin that addresses this issue. My project - jQuery.floatThead has been around for over 4 years now and is very mature.

It requires no external styles and does not expect your table to be styled in any particular way. It supports Internet Explorer9+ and Firefox/Chrome.

Currently (2018-05) it has:

405 commits and 998 stars on GitHub


Many (not all) of the answers here are quick hacks that may have solved the problem one person was having, but will work not for every table.

Some of the other plugins are old and probably work great with Internet Explorer, but will break on Firefox and Chrome.


Additional to @Daniel Waltrip answer. Table need to enclose with div position: relative in order to work with position:sticky . So I would like to post my sample code here.

CSS

/* Set table width/height as you want.*/
div.freeze-header {
  position: relative;
  max-height: 150px;
  max-width: 400px;
  overflow:auto;
}

/* Use position:sticky to freeze header on top*/
div.freeze-header > table > thead > tr > th {
  position: sticky;
  top: 0;
  background-color:yellow;
}

/* below is just table style decoration.*/
div.freeze-header > table {
  border-collapse: collapse;
}

div.freeze-header > table td {
  border: 1px solid black;
}

HTML

<html>
<body>
  <div>
   other contents ...
  </div>
  <div>
   other contents ...
  </div>
  <div>
   other contents ...
  </div>

  <div class="freeze-header">
    <table>
       <thead>
         <tr>
           <th> header 1 </th>
           <th> header 2 </th>
           <th> header 3 </th>
           <th> header 4 </th>
           <th> header 5 </th>
           <th> header 6 </th>
           <th> header 7 </th>
           <th> header 8 </th>
           <th> header 9 </th>
           <th> header 10 </th>
           <th> header 11 </th>
           <th> header 12 </th>
           <th> header 13 </th>
           <th> header 14 </th>
           <th> header 15 </th>
          </tr>
       </thead>
       <tbody>
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
         <tr>
           <td> data 1 </td>
           <td> data 2 </td>
           <td> data 3 </td>
           <td> data 4 </td>
           <td> data 5 </td>
           <td> data 6 </td>
           <td> data 7 </td>
           <td> data 8 </td>
           <td> data 9 </td>
           <td> data 10 </td>
           <td> data 11 </td>
           <td> data 12 </td>
           <td> data 13 </td>
           <td> data 14 </td>
           <td> data 15 </td>
          </tr>         
       </tbody>
    </table>
  </div>
</body>
</html>

Demo

enter image description here


I developed a simple light-weight jQuery plug-in for converting a well formatted HTML table to a scrollable table with fixed table header and columns.

The plugin works well to match pixel-to-pixel positioning the fixed section with the scrollable section. Additionally, you could also freeze the number of columns that will be always in view when scrolling horizontally.

Demo & Documentation: http://meetselva.github.io/fixed-table-rows-cols/

GitHub repository: https://github.com/meetselva/fixed-table-rows-cols

Below is the usage for a simple table with a fixed header,

$(<table selector>).fxdHdrCol({
    width:     "100%",
    height:    200,
    colModal: [{width: 30, align: 'center'},
               {width: 70, align: 'center'}, 
               {width: 200, align: 'left'}, 
               {width: 100, align: 'center'}, 
               {width: 70, align: 'center'}, 
               {width: 250, align: 'center'}
              ]
});

All of the attempts to solve this from outside the CSS specification are pale shadows of what we really want: Delivery on the implied promise of THEAD.

This frozen-headers-for-a-table issue has been an open wound in HTML/CSS for a long time.

In a perfect world, there would be a pure-CSS solution for this problem. Unfortunately, there doesn't seem to be a good one in place.

Relevant standards-discussions on this topic include:

UPDATE: Firefox shipped position:sticky in version 32. Everyone wins!


Most of the solutions posted here require jQuery. If you are looking for a framework independent solution try Grid: http://www.matts411.com/post/grid/

It's hosted on Github here: https://github.com/mmurph211/Grid

Not only does it support fixed headers, it also supports fixed left columns and footers, among other things.


:)

Not-so-clean, but pure HTML/CSS solution.

table {
    overflow-x:scroll;
}

tbody {
    max-height: /*your desired max height*/
    overflow-y:scroll;
    display:block;
}

Updated for IE8+ JSFiddle example


This can be cleanly solved in four lines of code.

If you only care about modern browsers, a fixed header can be achieved much easier by using CSS transforms. Sounds odd, but works great:

  • HTML and CSS stay as-is.
  • No external JavaScript dependencies.
  • Four lines of code.
  • Works for all configurations (table-layout: fixed, etc.).
document.getElementById("wrap").addEventListener("scroll", function(){
   var translate = "translate(0,"+this.scrollTop+"px)";
   this.querySelector("thead").style.transform = translate;
});

Support for CSS transforms is widely available except for Internet Explorer 8-.

Here is the full example for reference:

_x000D_
_x000D_
document.getElementById("wrap").addEventListener("scroll",function(){_x000D_
   var translate = "translate(0,"+this.scrollTop+"px)";_x000D_
   this.querySelector("thead").style.transform = translate;_x000D_
});
_x000D_
/* Your existing container */_x000D_
#wrap {_x000D_
    overflow: auto;_x000D_
    height: 400px;_x000D_
}_x000D_
_x000D_
/* CSS for demo */_x000D_
td {_x000D_
    background-color: green;_x000D_
    width: 200px;_x000D_
    height: 100px;_x000D_
}
_x000D_
<div id="wrap">_x000D_
    <table>_x000D_
        <thead>_x000D_
            <tr>_x000D_
                <th>Foo</th>_x000D_
                <th>Bar</th>_x000D_
            </tr>_x000D_
        </thead>_x000D_
        <tbody>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
            <tr><td></td><td></td></tr>_x000D_
        </tbody>_x000D_
    </table>_x000D_
</div>
_x000D_
_x000D_
_x000D_


TL;DR

If you target modern browsers and don't have extravagant styling needs: http://jsfiddle.net/dPixie/byB9d/3/ ... Although the big four version is pretty sweet as well this version handles fluid width a lot better.

Good news everyone!

With the advances of HTML5 and CSS3 this is now possible, at least for modern browsers. The slightly hackish implementation I came up with can be found here: http://jsfiddle.net/dPixie/byB9d/3/. I have tested it in FX 25, Chrome 31 and IE 10 ...

Relevant HTML (insert a HTML5 doctype at the top of your document though):

_x000D_
_x000D_
html,_x000D_
body {_x000D_
  margin: 0;_x000D_
  padding: 0;_x000D_
  height: 100%;_x000D_
}_x000D_
_x000D_
section {_x000D_
  position: relative;_x000D_
  border: 1px solid #000;_x000D_
  padding-top: 37px;_x000D_
  background: #500;_x000D_
}_x000D_
_x000D_
section.positioned {_x000D_
  position: absolute;_x000D_
  top: 100px;_x000D_
  left: 100px;_x000D_
  width: 800px;_x000D_
  box-shadow: 0 0 15px #333;_x000D_
}_x000D_
_x000D_
.container {_x000D_
  overflow-y: auto;_x000D_
  height: 200px;_x000D_
}_x000D_
_x000D_
table {_x000D_
  border-spacing: 0;_x000D_
  width: 100%;_x000D_
}_x000D_
_x000D_
td+td {_x000D_
  border-left: 1px solid #eee;_x000D_
}_x000D_
_x000D_
td,_x000D_
th {_x000D_
  border-bottom: 1px solid #eee;_x000D_
  background: #ddd;_x000D_
  color: #000;_x000D_
  padding: 10px 25px;_x000D_
}_x000D_
_x000D_
th {_x000D_
  height: 0;_x000D_
  line-height: 0;_x000D_
  padding-top: 0;_x000D_
  padding-bottom: 0;_x000D_
  color: transparent;_x000D_
  border: none;_x000D_
  white-space: nowrap;_x000D_
}_x000D_
_x000D_
th div {_x000D_
  position: absolute;_x000D_
  background: transparent;_x000D_
  color: #fff;_x000D_
  padding: 9px 25px;_x000D_
  top: 0;_x000D_
  margin-left: -25px;_x000D_
  line-height: normal;_x000D_
  border-left: 1px solid #800;_x000D_
}_x000D_
_x000D_
th:first-child div {_x000D_
  border: none;_x000D_
}
_x000D_
<section class="positioned">_x000D_
  <div class="container">_x000D_
    <table>_x000D_
      <thead>_x000D_
        <tr class="header">_x000D_
          <th>_x000D_
            Table attribute name_x000D_
            <div>Table attribute name</div>_x000D_
          </th>_x000D_
          <th>_x000D_
            Value_x000D_
            <div>Value</div>_x000D_
          </th>_x000D_
          <th>_x000D_
            Description_x000D_
            <div>Description</div>_x000D_
          </th>_x000D_
        </tr>_x000D_
      </thead>_x000D_
      <tbody>_x000D_
        <tr>_x000D_
          <td>align</td>_x000D_
          <td>left, center, right</td>_x000D_
          <td>Not supported in HTML5. Deprecated in HTML 4.01. Specifies the alignment of a table according to surrounding text</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>bgcolor</td>_x000D_
          <td>rgb(x,x,x), #xxxxxx, colorname</td>_x000D_
          <td>Not supported in HTML5. Deprecated in HTML 4.01. Specifies the background color for a table</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>border</td>_x000D_
          <td>1,""</td>_x000D_
          <td>Specifies whether the table cells should have borders or not</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>cellpadding</td>_x000D_
          <td>pixels</td>_x000D_
          <td>Not supported in HTML5. Specifies the space between the cell wall and the cell content</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>cellspacing</td>_x000D_
          <td>pixels</td>_x000D_
          <td>Not supported in HTML5. Specifies the space between cells</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>frame</td>_x000D_
          <td>void, above, below, hsides, lhs, rhs, vsides, box, border</td>_x000D_
          <td>Not supported in HTML5. Specifies which parts of the outside borders that should be visible</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>rules</td>_x000D_
          <td>none, groups, rows, cols, all</td>_x000D_
          <td>Not supported in HTML5. Specifies which parts of the inside borders that should be visible</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>summary</td>_x000D_
          <td>text</td>_x000D_
          <td>Not supported in HTML5. Specifies a summary of the content of a table</td>_x000D_
        </tr>_x000D_
        <tr>_x000D_
          <td>width</td>_x000D_
          <td>pixels, %</td>_x000D_
          <td>Not supported in HTML5. Specifies the width of a table</td>_x000D_
        </tr>_x000D_
      </tbody>_x000D_
    </table>_x000D_
  </div>_x000D_
</section>
_x000D_
_x000D_
_x000D_

But how?!

Simply put you have a table header, that you visually hide by making it 0px high, that also contains divs used as the fixed header. The table's container leaves enough room at the top to allow for the absolutely positioned header, and the table with scrollbars appear as you would expect.

The code above uses the positioned class to position the table absolutely (I'm using it in a popup style dialog) but you can use it in the flow of the document as well by removing the positioned class from the container.

But ...

It's not perfect. Firefox refuses to make the header row 0px (at least I did not find any way) but stubbornly keeps it at minimum 4px ... It's not a huge problem, but depending on your styling it will mess with your borders etc.

The table is also using a faux column approach where the background color of the container itself is used as the background for the header divs, that are transparent.

Summary

All in all there might be styling issues depending on your requirements, especially borders or complicated backgrounds. There might also be problems with computability, I haven't checked it in a wide variety of browsers yet (please comment with your experiences if you try it out), but I didn't find anything like it so I thought it was worth posting anyway ...


I wish I had found @Mark's solution earlier, but I went and wrote my own before I saw this SO question...

Mine is a very lightweight jQuery plugin that supports fixed header, footer, column spanning (colspan), resizing, horizontal scrolling, and an optional number of rows to display before scrolling starts.

jQuery.scrollTableBody (GitHub)

As long as you have a table with proper <thead>, <tbody>, and (optional) <tfoot>, all you need to do is this:

$('table').scrollTableBody();

The CSS property position: sticky has great support in most modern browsers (I had issues with Edge, see below).

This lets us solve the problem of fixed headers quite easily:

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

Safari needs a vendor prefix: -webkit-sticky.

For Firefox, I had to add min-height: 0 to one the parent elements. I forget exactly why this was needed.

Most unfortunately, the Microsoft Edge implementation seems to be only semi-working. At least, I had some flickering and misaligned table cells in my testing. The table was still usable, but had significant aesthetic issues.


Use the latest version of jQuery, and include the following JavaScript code.

$(window).scroll(function(){
  $("id of the div element").offset({top:$(window).scrollTop()});
});

Here's a solution that we ended up working with (in order to deal with some edge cases and older versions of Internet Explorer, we eventually also faded out the title bar on scroll then faded it back in when scrolling ends, but in Firefox and WebKit browsers this solution just works. It assumes border-collapse: collapse.

The key to this solution is that once you apply border-collapse, CSS transforms work on the header, so it's just a matter of intercepting scroll events and setting the transform correctly. You don't need to duplicate anything. Short of this behavior being implemented properly in the browser, it's hard to imagine a more light-weight solution.

JSFiddle: http://jsfiddle.net/podperson/tH9VU/2/

It's implemented as a simple jQuery plugin. You simply make your thead's sticky with a call like $('thead').sticky(), and they'll hang around. It works for multiple tables on a page and head sections halfway down big tables.

$.fn.sticky = function(){
    $(this).each( function(){
        var thead = $(this),
            tbody = thead.next('tbody');

        updateHeaderPosition();

        function updateHeaderPosition(){
            if(
                thead.offset().top < $(document).scrollTop()
                && tbody.offset().top + tbody.height() > $(document).scrollTop()
            ){
                var tr = tbody.find('tr').last(),
                    y = tr.offset().top - thead.height() < $(document).scrollTop()
                        ? tr.offset().top - thead.height() - thead.offset().top
                        : $(document).scrollTop() - thead.offset().top;

                thead.find('th').css({
                    'z-index': 100,
                    'transform': 'translateY(' + y + 'px)',
                    '-webkit-transform': 'translateY(' + y + 'px)'
                });
            } else {
                thead.find('th').css({
                    'transform': 'none',
                    '-webkit-transform': 'none'
                });
            }
        }

        // See http://www.quirksmode.org/dom/events/scroll.html
        $(window).on('scroll', updateHeaderPosition);
    });
}

$('thead').sticky();

A more refined pure CSS scrolling table

All of the pure CSS solutions I've seen so far-- clever though they may be-- lack a certain level of polish, or just don't work right in some situations. So, I decided to create my own...

Features:

  • It's pure CSS, so no jQuery required (or any JavaScript code at all, for that matter)
  • You can set the table width to a percent (a.k.a. "fluid") or a fixed value, or let the content determine its width (a.k.a. "auto")
  • Column widths can also be fluid, fixed, or auto.
  • Columns will never become misaligned with headers due to horizontal scrolling (a problem that occurs in every other CSS-based solution I've seen that doesn't require fixed widths).
  • Compatible with all of the popular desktop browsers, including Internet Explorer back to version 8
  • Clean, polished appearance; no sloppy-looking 1-pixel gaps or misaligned borders; looks the same in all browsers

Here are a couple of fiddles that show the fluid and auto width options:

  • Fluid Width and Height (adapts to screen size): jsFiddle (Note that the scrollbar only shows up when needed in this configuration, so you may have to shrink the frame to see it)

  • Auto Width, Fixed Height (easier to integrate with other content): jsFiddle

The Auto Width, Fixed Height configuration probably has more use cases, so I'll post the code below.

_x000D_
_x000D_
/* The following 'html' and 'body' rule sets are required only
   if using a % width or height*/

/*html {
  width: 100%;
  height: 100%;
}*/

body {
  box-sizing: border-box;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0 20px 0 20px;
  text-align: center;
}
.scrollingtable {
  box-sizing: border-box;
  display: inline-block;
  vertical-align: middle;
  overflow: hidden;
  width: auto; /* If you want a fixed width, set it here, else set to auto */
  min-width: 0/*100%*/; /* If you want a % width, set it here, else set to 0 */
  height: 188px/*100%*/; /* Set table height here; can be fixed value or % */
  min-height: 0/*104px*/; /* If using % height, make this large enough to fit scrollbar arrows + caption + thead */
  font-family: Verdana, Tahoma, sans-serif;
  font-size: 16px;
  line-height: 20px;
  padding: 20px 0 20px 0; /* Need enough padding to make room for caption */
  text-align: left;
  color: black;
}
.scrollingtable * {box-sizing: border-box;}
.scrollingtable > div {
  position: relative;
  border-top: 1px solid black;
  height: 100%;
  padding-top: 20px; /* This determines column header height */
}
.scrollingtable > div:before {
  top: 0;
  background: cornflowerblue; /* Header row background color */
}
.scrollingtable > div:before,
.scrollingtable > div > div:after {
  content: "";
  position: absolute;
  z-index: -1;
  width: 100%;
  height: 100%;
  left: 0;
}
.scrollingtable > div > div {
  min-height: 0/*43px*/; /* If using % height, make this large
                            enough to fit scrollbar arrows */
  max-height: 100%;
  overflow: scroll/*auto*/; /* Set to auto if using fixed
                               or % width; else scroll */
  overflow-x: hidden;
  border: 1px solid black; /* Border around table body */
}
.scrollingtable > div > div:after {background: white;} /* Match page background color */
.scrollingtable > div > div > table {
  width: 100%;
  border-spacing: 0;
  margin-top: -20px; /* Inverse of column header height */
  /*margin-right: 17px;*/ /* Uncomment if using % width */
}
.scrollingtable > div > div > table > caption {
  position: absolute;
  top: -20px; /*inverse of caption height*/
  margin-top: -1px; /*inverse of border-width*/
  width: 100%;
  font-weight: bold;
  text-align: center;
}
.scrollingtable > div > div > table > * > tr > * {padding: 0;}
.scrollingtable > div > div > table > thead {
  vertical-align: bottom;
  white-space: nowrap;
  text-align: center;
}
.scrollingtable > div > div > table > thead > tr > * > div {
  display: inline-block;
  padding: 0 6px 0 6px; /*header cell padding*/
}
.scrollingtable > div > div > table > thead > tr > :first-child:before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  height: 20px; /*match column header height*/
  border-left: 1px solid black; /*leftmost header border*/
}
.scrollingtable > div > div > table > thead > tr > * > div[label]:before,
.scrollingtable > div > div > table > thead > tr > * > div > div:first-child,
.scrollingtable > div > div > table > thead > tr > * + :before {
  position: absolute;
  top: 0;
  white-space: pre-wrap;
  color: white; /*header row font color*/
}
.scrollingtable > div > div > table > thead > tr > * > div[label]:before,
.scrollingtable > div > div > table > thead > tr > * > div[label]:after {content: attr(label);}
.scrollingtable > div > div > table > thead > tr > * + :before {
  content: "";
  display: block;
  min-height: 20px; /* Match column header height */
  padding-top: 1px;
  border-left: 1px solid black; /* Borders between header cells */
}
.scrollingtable .scrollbarhead {float: right;}
.scrollingtable .scrollbarhead:before {
  position: absolute;
  width: 100px;
  top: -1px; /* Inverse border-width */
  background: white; /* Match page background color */
}
.scrollingtable > div > div > table > tbody > tr:after {
  content: "";
  display: table-cell;
  position: relative;
  padding: 0;
  border-top: 1px solid black;
  top: -1px; /* Inverse of border width */
}
.scrollingtable > div > div > table > tbody {vertical-align: top;}
.scrollingtable > div > div > table > tbody > tr {background: white;}
.scrollingtable > div > div > table > tbody > tr > * {
  border-bottom: 1px solid black;
  padding: 0 6px 0 6px;
  height: 20px; /* Match column header height */
}
.scrollingtable > div > div > table > tbody:last-of-type > tr:last-child > * {border-bottom: none;}
.scrollingtable > div > div > table > tbody > tr:nth-child(even) {background: gainsboro;} /* Alternate row color */
.scrollingtable > div > div > table > tbody > tr > * + * {border-left: 1px solid black;} /* Borders between body cells */
_x000D_
<div class="scrollingtable">
  <div>
    <div>
      <table>
        <caption>Top Caption</caption>
        <thead>
          <tr>
            <th><div label="Column 1"/></th>
            <th><div label="Column 2"/></th>
            <th><div label="Column 3"/></th>
            <th>
              <!-- More versatile way of doing column label; requires two identical copies of label -->
              <div><div>Column 4</div><div>Column 4</div></div>
            </th>
            <th class="scrollbarhead"/> <!-- ALWAYS ADD THIS EXTRA CELL AT END OF HEADER ROW -->
          </tr>
        </thead>
        <tbody>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
          <tr><td>Lorem ipsum</td><td>Dolor</td><td>Sit</td><td>Amet consectetur</td></tr>
        </tbody>
      </table>
    </div>
    Faux bottom caption
  </div>
</div>

<!--[if lte IE 9]><style>.scrollingtable > div > div > table {margin-right: 17px;}</style><![endif]-->
_x000D_
_x000D_
_x000D_

The method I used to freeze the header row is similar to d-Pixie's, so refer to his post for an explanation. There were a slew of bugs and limitations with that technique that could only be fixed with heaps of additional CSS and an extra div container or two.


Two divs, one for header, one for data. Make the data div scrollable, and use JavaScript to set the width of the columns in the header to be the same as the widths in the data. I think the data columns widths need to be fixed rather than dynamic.


<html>
<head>
    <script src="//cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js"></script>
    <script>
        function stickyTableHead (tableID) {
            var $tmain = $(tableID);
            var $tScroll = $tmain.children("thead")
                .clone()
                .wrapAll('<table id="tScroll" />')
                .parent()
                .addClass($(tableID).attr("class"))
                .css("position", "fixed")
                .css("top", "0")
                .css("display", "none")
                .prependTo("#tMain");

            var pos = $tmain.offset().top + $tmain.find(">thead").height();


            $(document).scroll(function () {
                var dataScroll = $tScroll.data("scroll");
                dataScroll = dataScroll || false;
                if ($(this).scrollTop() >= pos) {
                    if (!dataScroll) {
                        $tScroll
                            .data("scroll", true)
                            .show()
                            .find("th").each(function () {
                                $(this).width($tmain.find(">thead>tr>th").eq($(this).index()).width());
                            });
                    }
                } else {
                    if (dataScroll) {
                        $tScroll
                            .data("scroll", false)
                            .hide()
                        ;
                    }
                }
            });
        }

        $(document).ready(function () {
            stickyTableHead('#tMain');
        });
    </script>
</head>

<body>
    gfgfdgsfgfdgfds<br/>
    gfgfdgsfgfdgfds<br/>
    gfgfdgsfgfdgfds<br/>
    gfgfdgsfgfdgfds<br/>
    gfgfdgsfgfdgfds<br/>
    gfgfdgsfgfdgfds<br/>

    <table id="tMain" >
        <thead>
        <tr>
            <th>1</th> <th>2</th><th>3</th> <th>4</th><th>5</th> <th>6</th><th>7</th> <th>8</th>

        </tr>
        </thead>
        <tbody>
            <tr><td>11111111111111111111111111111111111111111111111111111111</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
            <tr><td>1</td><td>2</td><td>3</td><td>4</td><td>5555555</td><td>66666666666</td><td>77777777777</td><td>8888888888888888</td></tr>
        </tbody>
    </table>
</body>
</html>

Here is a jQuery plugin for fixed table headers. It allows the entire page to scroll, freezing the header when it reaches the top. It works well with Twitter Bootstrap tables.

GitHub repository: https://github.com/oma/table-fixed-header

It does not scroll only table content. Look to other tools for that, as one of these other answers. You decide what fits your case the best.


A simple jQuery plugin

This is a variation on Mahes' solution. You can call it like $('table#foo').scrollableTable();

The idea is:

  • Split the thead and tbody into separate table elements
  • Make their cell widths match again
  • Wrap the second table in a div.scrollable
  • Use CSS to make div.scrollable actually scroll

The CSS could be:

div.scrollable { height: 300px; overflow-y: scroll;}

Caveats

  • Obviously, splitting up these tables makes the markup less semantic. I'm not sure what effect this has on accessibility.
  • This plugin does not deal with footers, multiple headers, etc.
  • I've only tested it in Chrome version 20.

That said, it works for my purposes and you're free to take and modify it.

Here's the plugin:

jQuery.fn.scrollableTable = function () {
  var $newTable, $oldTable, $scrollableDiv, originalWidths;
  $oldTable = $(this);

  // Once the tables are split, their cell widths may change. 
  // Grab these so we can make the two tables match again.
  originalWidths = $oldTable.find('tr:first td').map(function() {
    return $(this).width();
  });

  $newTable = $oldTable.clone();
  $oldTable.find('tbody').remove();
  $newTable.find('thead').remove();

  $.each([$oldTable, $newTable], function(index, $table) {
    $table.find('tr:first td').each(function(i) {
      $(this).width(originalWidths[i]);
    });
  });

  $scrollableDiv = $('<div/>').addClass('scrollable');
  $newTable.insertAfter($oldTable).wrap($scrollableDiv);
};

Somehow I ended up with Position:Sticky working fine on my case:

_x000D_
_x000D_
table{_x000D_
  width: 100%;_x000D_
  border: collapse;_x000D_
}_x000D_
_x000D_
th{_x000D_
    position: sticky;_x000D_
    top: 0px;_x000D_
    border: 1px solid black;_x000D_
    background: #ff5722;_x000D_
    color: #f5f5f5;_x000D_
    font-weight: 600;_x000D_
}_x000D_
td{_x000D_
    background: #d3d3d3;_x000D_
    border: 1px solid black;_x000D_
    color: #f5f5f5;_x000D_
    font-weight: 600;_x000D_
}_x000D_
_x000D_
div{_x000D_
  height: 150px_x000D_
  overflow: auto;_x000D_
  width: 100%_x000D_
}
_x000D_
<div>_x000D_
    <table>_x000D_
        <thead>_x000D_
            <tr>_x000D_
                <th>header 1</th>_x000D_
                <th>header 2</th>_x000D_
                <th>header 3</th>_x000D_
                <th>header 4</th>_x000D_
                <th>header 5</th>_x000D_
                <th>header 6</th>_x000D_
                <th>header 7</th>_x000D_
            </tr>_x000D_
        </thead>_x000D_
        <tbody>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
            <tr>_x000D_
                <td>data 1</td>_x000D_
                <td>data 2</td>_x000D_
                <td>data 3</td>_x000D_
                <td>data 4</td>_x000D_
                <td>data 5</td>_x000D_
                <td>data 6</td>_x000D_
                <td>data 7</td>_x000D_
            </tr>_x000D_
        </tbody>_x000D_
    </table>_x000D_
</div>
_x000D_
_x000D_
_x000D_


For those who tried the nice solution given by Maximilian Hils, and did not succeed to get it to work with Internet Explorer, I had the same problem (Internet Explorer 11) and found out what was the problem.

In Internet Explorer 11 the style transform (at least with translate) does not work on <THEAD>. I solved this by instead applying the style to all the <TH> in a loop. That worked. My JavaScript code looks like this:

document.getElementById('pnlGridWrap').addEventListener("scroll", function () {
  var translate = "translate(0," + this.scrollTop + "px)";
  var myElements = this.querySelectorAll("th");
  for (var i = 0; i < myElements.length; i++) {
    myElements[i].style.transform=translate;
  }
});

In my case the table was a GridView in ASP.NET. First I thought it was because it had no <THEAD>, but even when I forced it to have one, it did not work. Then I found out what I wrote above.

It is a very nice and simple solution. On Chrome it is perfect, on Firefox a bit jerky, and on Internet Explorer even more jerky. But all in all a good solution.


I realize the question allows JavaScript, but here is a pure CSS solution I worked up that also allows for the table to expand horizontally. It was tested with Internet Explorer 10 and the latest Chrome and Firefox browsers. A link to jsFiddle is at the bottom.

The HTML:

Putting some text here to differentiate between the header
aligning with the top of the screen and the header aligning
with the top of one of its ancestor containers.

<div id="positioning-container">
<div id="scroll-container">
    <table>
        <colgroup>
            <col class="col1"></col>
            <col class="col2"></col>
        </colgroup>
        <thead>
            <th class="header-col1"><div>Header 1</div></th>
            <th class="header-col2"><div>Header 2</div></th>
        </thead>
        <tbody>
            <tr><td>Cell 1.1</td><td>Cell 1.2</td></tr>
            <tr><td>Cell 2.1</td><td>Cell 2.2</td></tr>
            <tr><td>Cell 3.1</td><td>Cell 3.2</td></tr>
            <tr><td>Cell 4.1</td><td>Cell 4.2</td></tr>
            <tr><td>Cell 5.1</td><td>Cell 5.2</td></tr>
            <tr><td>Cell 6.1</td><td>Cell 6.2</td></tr>
            <tr><td>Cell 7.1</td><td>Cell 7.2</td></tr>

        </tbody>
    </table>
</div>
</div>

And the CSS:

table{
    border-collapse: collapse;
    table-layout: fixed;
    width: 100%;
}
/* Not required, just helps with alignment for this example */
td, th{
    padding: 0;
    margin: 0;
}

tbody{
    background-color: #ddf;
}

thead {
    /* Keeps the header in place. Don't forget top: 0 */
    position: absolute;
    top: 0;
    background-color: #ddd;

    /* The 17px is to adjust for the scrollbar width.
     * This is a new css value that makes this pure
     * css example possible */
    width: calc(100% - 17px);
    height: 20px;
}

/* Positioning container. Required to position the
 * header since the header uses position:absolute
 * (otherwise it would position at the top of the screen) */
#positioning-container{
    position: relative;
}

/* A container to set the scroll-bar and
 * includes padding to move the table contents
 * down below the header (padding = header height) */
#scroll-container{
    overflow-y: auto;
    padding-top: 20px;
    height: 100px;
}
.header-col1{
    background-color: red;
}

/* Fixed-width header columns need a div to set their width */
.header-col1 div{
    width: 100px;
}

/* Expandable columns need a width set on the th tag */
.header-col2{
    width: 100%;
}
.col1 {
    width: 100px;
}
.col2{
    width: 100%;
}

http://jsfiddle.net/HNHRv/3/


I like Maximillian Hils' answer but I had a some issues:

  1. the transform doesn't work in Edge or IE unless you apply it to the th
  2. the header flickers during scrolling in Edge and IE
  3. my table is loaded using ajax, so I wanted to attach to the window scroll event rather than the wrapper's scroll event

To get rid of the flicker, I use a timeout to wait until the user has finished scrolling, then I apply the transform - so the header is not visible during scrolling.

I have also written this using jQuery, one advantage of that being that jQuery should handle vendor-prefixes for you

    var isScrolling, lastTop, lastLeft, isLeftHidden, isTopHidden;

    //Scroll events don't bubble https://stackoverflow.com/a/19375645/150342
    //so can't use $(document).on("scroll", ".table-container-fixed", function (e) {
    document.addEventListener('scroll', function (event) {
        var $container = $(event.target);
        if (!$container.hasClass("table-container-fixed"))
            return;    

        //transform needs to be applied to th for Edge and IE
        //in this example I am also fixing the leftmost column
        var $topLeftCell = $container.find('table:first > thead > tr > th:first');
        var $headerCells = $topLeftCell.siblings();
        var $columnCells = $container
           .find('table:first > tbody > tr > td:first-child, ' +
                 'table:first > tfoot > tr > td:first-child');

        //hide the cells while returning otherwise they show on top of the data
        if (!isLeftHidden) {
            var currentLeft = $container.scrollLeft();
            if (currentLeft < lastLeft) {
                //scrolling left
                isLeftHidden = true;
                $topLeftCell.css('visibility', 'hidden');
                $columnCells.css('visibility', 'hidden');
            }
            lastLeft = currentLeft;
        }

        if (!isTopHidden) {
            var currentTop = $container.scrollTop();
            if (currentTop < lastTop) {
                //scrolling up
                isTopHidden = true;
                $topLeftCell.css('visibility', 'hidden');
                $headerCells.css('visibility', 'hidden');
            }
            lastTop = currentTop;
        }

        // Using timeout to delay transform until user stops scrolling
        // Clear timeout while scrolling
        window.clearTimeout(isScrolling);

        // Set a timeout to run after scrolling ends
        isScrolling = setTimeout(function () {
            //move the table cells. 
            var x = $container.scrollLeft();
            var y = $container.scrollTop();

            $topLeftCell.css('transform', 'translate(' + x + 'px, ' + y + 'px)');
            $headerCells.css('transform', 'translateY(' + y + 'px)');
            $columnCells.css('transform', 'translateX(' + x + 'px)');

            isTopHidden = isLeftHidden = false;
            $topLeftCell.css('visibility', 'inherit');
            $headerCells.css('visibility', 'inherit');
            $columnCells.css('visibility', 'inherit');
        }, 100);

    }, true);

The table is wrapped in a div with the class table-container-fixed.

.table-container-fixed{
    overflow: auto;
    height: 400px;
}

I set border-collapse to separate because otherwise we lose borders during translation, and I remove the border on the table to stop content appearing just above the cell where the border was during scrolling.

.table-container-fixed > table {
   border-collapse: separate;
   border:none;
}

I make the th background white to cover the cells underneath, and I add a border that matches the table border - which is styled using Bootstrap and scrolled out of view.

 .table-container-fixed > table > thead > tr > th {
        border-top: 1px solid #ddd !important;
        background-color: white;        
        z-index: 10;
        position: relative;/*to make z-index work*/
    }

            .table-container-fixed > table > thead > tr > th:first-child {
                z-index: 20;
            }

.table-container-fixed > table > tbody > tr > td:first-child,
.table-container-fixed > table > tfoot > tr > td:first-child {
    background-color: white;        
    z-index: 10;
    position: relative;
}

Examples related to javascript

need to add a class to an element How to make a variable accessible outside a function? Hide Signs that Meteor.js was Used How to create a showdown.js markdown extension Please help me convert this script to a simple image slider Highlight Anchor Links when user manually scrolls? Summing radio input values How to execute an action before close metro app WinJS javascript, for loop defines a dynamic variable name Getting all files in directory with ajax

Examples related to css

need to add a class to an element Using Lato fonts in my css (@font-face) Please help me convert this script to a simple image slider Why there is this "clear" class before footer? How to set width of mat-table column in angular? Center content vertically on Vuetify bootstrap 4 file input doesn't show the file name Bootstrap 4: responsive sidebar menu to top navbar Stylesheet not loaded because of MIME-type Force flex item to span full row width

Examples related to html-table

Table column sizing DataTables: Cannot read property 'length' of undefined TypeError: a bytes-like object is required, not 'str' in python and CSV How to get the <td> in HTML tables to fit content, and let a specific <td> fill in the rest How to stick table header(thead) on top while scrolling down the table rows with fixed header(navbar) in bootstrap 3? Sorting table rows according to table header column using javascript or jquery How to make background of table cell transparent How to auto adjust table td width from the content bootstrap responsive table content wrapping How to print table using Javascript?