Sticky Table Headers & Columns

A tutorial on how to create sticky headers and columns for tables using jQuery. The solution is an alternative to other sticky table header approaches and it addresses the overflowing table problem including adding support for biaxial headers.

Sticky table headers are no longer a stranger to an average website user — unlike on paper when a reader’s eyes can comfortably jump in saccades between top of a lengthy table and the rows of interest, the landscape orientation of most devices makes vertically-long tables hard to read. However, this very conundrum presents itself as a rich ground for UI experimentation that is not available to the printed media.

Sticky table headers, as their name implies, remains affixed to the top of the viewport even when the original table headers are scrolled out of view. They help to clarify the representation and purpose of data in columns when the visual reference to original table headers was lost. Besides that, the aid in orienting users in a sea of tabulated information, therefore avoiding the need to repeatedly, and frustratingly, scroll between the top of the table where the header resides, and the region of interest further down, typically lying out of the viewport.

There have been a handful of scripts and jQuery plugins written for the purpose of re-establishing the flow and ease of reading tables. While their implementation is flawless and efficient, they might not be an all-encompassing panacea for long tables. In some cases, tables have to obey certain layout rules that are not accounted for by the aforementioned plugins — such as tables that are forced to overflow due to dimension restrictions (e.g. to fit within a viewport).

While this tutorial does not try to serve as an all-encompassing panacea to the decidedly sticky problem with sticky table headers, it addresses more possible layout scenarios.

A pure CSS-based solution with position: sticky?

Last September, a somewhat promising solution surfaced — a new possible value for the CSS position property is supported in the latest nightly build of WebKit or Chrome Canary. position: sticky sounded like a very promising new JS-free solution to the old and nuance problems of rigid table headers and beyond — its implementation can be also extremely useful in scenarios where a site navigation or a HTML5 app toolbar has to remain in view to the user at all times regardless of his/her scroll position along the document’s y-axis.

Moreover, the sticky property value is supported in barely 6% of all global visits, making it a poor candidate for choice of implementation. Although it will not break layouts as browsers are dictated by W3C directive to ignore properties values that are unrecognized, invalid or illegal, it is not an ideal candidate when cross-browser functionality is desired.

The jQuery-based solution

The jQuery-based solution is rather straight-forward. Before we move on with the JS itself, we should come to a common consensus how a semantically valid table should look like in the markup:

<table>
    <thead>
        <tr>
            <th></th>
            <!-- more columns are possible -->
    </tr>
    </thead>
    <tbody>
        <tr>
            <td></td>
            <!-- more columns are possible -->
        </tr>
        <!-- more rows are possible -->
    </tbody>
    <tfoot><!-- optional -->
        <tr>
            <td></td>
        </tr>
    </tfoot>
</table>

What do we want to achieve?

We should enumerate the expectations of this script. It would be great if the script can accommodate various table layouts and situations:

  • Basic usage: Sticky table header only
  • Biaxial table headers
  • Wide tables:
    • Horizontal overflow: If there is a row header, we should introduce a sticky table column, too
    • Vertical overflow: Covered in basic usage
    • Biaxial overflow: Introduce sticky table header and column

Some CSS groundwork

Despite choosing to work with a JS-based solution, we will still have to rely on CSS for the basic styling of the headers. The important things is that we have to position the sticky header absolutely within a common parent with its full-fledge, original table sibling. The CSS is rather straight forward:

.sticky-wrap {
    overflow-x: auto;
    position: relative;
    margin-bottom: 1.5em;
    width: 100%;
}
.sticky-wrap .sticky-thead,
.sticky-wrap .sticky-col,
.sticky-wrap .sticky-intersect {
    opacity: 0;
    position: absolute;
    top: 0;
    left: 0;
    transition: all .125s ease-in-out;
    z-index: 50;
    width: auto; /* Prevent table from stretching to full size */
}
    .sticky-wrap .sticky-thead {
        box-shadow: 0 0.25em 0.1em -0.1em rgba(0,0,0,.125);
        z-index: 100;
        width: 100%; /* Force stretch */
    }
    .sticky-wrap .sticky-intersect {
        opacity: 1;
        z-index: 150;
    }
    .sticky-wrap .sticky-intersect th {
        background-color: #666;
        color: #eee;
    }
.sticky-wrap td,
.sticky-wrap th {
    box-sizing: border-box;
}

Note: It is extremely important that you port the styles for your <table> elements over to .sticky-wrap. Although margins of pixel values can be easily calculated and applied to the new wrapper element, automatic margins are difficult to deal with (it is not possible to fetch the value of auto with jQuery in a straightforward manner), and it is easier if we simply apply the margins and width of tables to the wrapper element itself.

Let’s say you have the following styles for your table:

table {
    margin: 0 auto 1.5em;
    width: 75%;
}

You can simply add the lines to “.sticky-wrap”, too:

.sticky-wrap {
    overflow-x: auto; /* Allows wide tables to overflow its containing parent */
    position: relative;
    margin: 0 auto 1.5em;
    width: 75%;
}

I shall walk you through the steps that will, with a dozens of lines of JavaScript, create functional sticky table headers. For the ease of presentation, the script is presented in a logical flow towards problem solving — declaration of variables with the var statement can definitely be concatenated for a more compact and compressed script, but at the sake of logical flow and readability, therefore I have chosen not to adopt the latter approach.

We shall execute our function for every single instance of table selected for upon DOM ready. Moreover, we will also want to check if the selected tables contain the <thead> element, and that the <thead> element is not empty and contains at least one <th> child. If the aforementioned criteria are not satisfied, our function will simply skip that instance of <table> and move on to the next.

$(function () {
    // Here we select for <table> elements universally,
    // but you can definitely fine tune your selector
    $('table').each(function () {
        if($(this).find('thead').length > 0 && $(this).find('th').length > 0) {
            // Rest of our script goes here
        }
    });
});

Step 1: Clone the <thead> element

Before we start, we will want to close the table head, and declare some shorthand variables for ease of use:

// Declare variables and shorthands
    var $t     = $(this),
        $w     = $(window),
        $thead = $(this).find('thead').clone(),
        $col   = $(this).find('thead, tbody').clone();

Step 2: Wrap table and append new tables

In order to extend compatibility towards tables that have excessive width along the x-axis (e.g. having too many columns, or columns that are necessarily yet excessively wide), we wrap the table elements in a <div> element that is allowed to overflow along the x-axis. The width and margin properties are reset for the table so as to allow proper display within the wrapper.

// Wrap table
$t
.addClass('sticky-enabled')
.css({
    margin: 0,
    width: '100%';
})
.wrap('<div class="sticky-wrap" />');

// Check if table is set to overflow in the y-axis
if($t.hasClass('overflow-y')) $t.removeClass('overflow-y').parent().addClass('overflow-y');

// Create new sticky table head (basic)
$t.after('<table class="sticky-head" />')

// If <tbody> contains <th>, then we create sticky column and intersect (advanced)
if($t.find('tbody th').length > 0) {
    $t.after('<table class="sticky-col" /><table class="sticky-intersect" />');
}
// Create shorthand for things
var $stickyHead  = $(this).siblings('.sticky-thead'),
    $stickyCol   = $(this).siblings('.sticky-col'),
    $stickyInsct = $(this).siblings('.sticky-intersect'),
    $stickyWrap  = $(this).parent('.sticky-wrap');

Step 3: Inserting cloned table contents

The trick now is to insert contents cloned from our original table into the newly created tables that will serve as our sticky elements:

  1. Sticky header will receive all contents from the cloned <thead> element
  2. Sticky column will receive contents from the first <th> from <thead>, and all the subsequent <th> from <tbody>. This is assuming that each row only contains one table header cell.
  3. Sticky intersect will receive content from the top left most cell in the table
// Sticky header gets all content from <thead>
$stickyHead.append($thead);

// Sticky column gets content from the first <th> of both <thead> and <tbody>
$stickyCol
.append($col)
    .find('thead th:gt(0)').remove()
    .end()
    .find('tbody td').remove();

// Sticky intersect gets content from the first <th> in <thead>
$stickyInsct.html('<thead><tr><th>'+$t.find('thead th:first-child').html()+'</th></tr></thead>');

Step 4: Functions

Here comes the most important part of our jQuery script — we decide what functions are needed for sticky headers to work, and we declare them with the var statement to allow for easy callback. Two functions immediately come to mind:

  1. A function to determine the widths of individual <th> elements in the cloned header. Since we only cloned the <thead> element, the computed width of the cloned header will not be the same as the actual header itself, since the content of <tbody> itself, which may or may not influence the final width of each individual columns, is not included.
  2. A function to position the sticky header, so that we can update the vertical offset of the cloned header that is absolutely positioned when the scroll event is fired.
  3. A function to position the sticky column, so that we can update the horizontal offset when the parent element is overflowing.
  4. A function to calculate allowance — this feature is explained later in greater detail.

You may ask, why do I have to calculate the vertical offset of the header instead of simply using position: fixed? I, too, have contemplated over this issue, but it came to my realization that if we are allowing the table to overflow along the x-axis, the fixed positioning option has to go out of the window, because it will not scroll with the table in the event of a horizontal overflow.

// Function 1: setWidths()
// Purpose: To set width of individually cloned element
var setWidths = function () {
        $t
        .find('thead th').each(function (i) {
            $stickyHead.find('th').eq(i).width($(this).width());
        })
        .end()
        .find('tr').each(function (i) {
            $stickyCol.find('tr').eq(i).height($(this).height());
        });

        // Set width of sticky table head
        $stickyHead.width($t.width());

        // Set width of sticky table col
        $stickyCol.find('th').add($stickyInsct.find('th')).width($t.find('thead th').width())

    },
// Function 2: repositionStickyHead()
// Purpose: To position the cloned sticky header (always present) appropriately
    repositionStickyHead = function () {
        // Return value of calculated allowance
        var allowance = calcAllowance();

        // Check if wrapper parent is overflowing along the y-axis
        if($t.height() > $stickyWrap.height()) {
            // If it is overflowing
            // Position sticky header based on wrapper's scrollTop()
            if($stickyWrap.scrollTop() > 0) {
                // When top of wrapping parent is out of view
                $stickyHead.add($stickyInsct).css({
                    opacity: 1,
                    top: $stickyWrap.scrollTop()
                });
            } else {
                // When top of wrapping parent is in view
                $stickyHead.add($stickyInsct).css({
                    opacity: 0,
                    top: 0
                });
            }
        } else {
            // If it is not overflowing (basic layout)
            // Position sticky header based on viewport scrollTop()
            if($w.scrollTop() > $t.offset().top && $w.scrollTop() < $t.offset().top + $t.outerHeight() - allowance) {                 // When top of viewport is within the table, and we set an allowance later
                // Action: Show sticky header and intersect, and set top to the right value
                $stickyHead.add($sticktInsct).css({
                    opacity: 1,
                   top: $w.scrollTop() - $t.offset().top
                });
             } else {
                 // When top of viewport is above or below table
                 // Action: Hide sticky header and intersect
                 $sticky.add($stickInsct).css({
                     opacity: 0,
                     top: 0
                 });
             }
        }
    },
// Function 3: repositionStickyCol()
// Purpose: To position the cloned sticky column (if present) appropriately
    repositionStickyCol = function () {
        if($stickyWrap.scrollLeft() > 0) {
            // When left of wrapping parent is out of view
            // Show sticky column and intersect
            $stickyCol.add($stickyInsct).css({
                opacity: 1,
                left: $stickyWrap.scrollLeft()
            });
        } else {
            // When left of wrapping parent is in view
            // Hide sticky column but not the intersect
            // Reset left position
            $stickyCol
            .css({ opacity: 0 })
            .add($stickyInsct).css({ left: 0 });
        }
    },
// Function 4: calcAllowance()
// Purpose: Return value of calculated allowance
     calcAllowance = function () {
         var a = 0;

         // Get sum of height of last three rows
         $t.find('tbody tr:lt(3)').each(function () {
             a += $(this).height();
         });

         // Set fail safe limit (last three row might be too tall)
         // Set arbitrary limit at 0.25 of viewport height, or you can use an arbitrary pixel value
         if(a > $w.height()*0.25) {
            a = $w.height()*0.25;
        }

        // Add height of sticky header itself
        a += $sticky.height();

        return a;
    };
}

Now, you may ask, what is allowance? What do we need it for? The basis of the allowance is simple — we do not want the sticky table header to follow us all the way to the end of the table, do we? It is unnecessary, and run the risk of obfuscating the last table row. While this feature is optional (thus allowance is set to 0, see above), I highly recommend allowing at least one table row of height remaining. The height can be computed from the table itself, or you can set a fixed height.

As far as my experience go, I realize that I do not need the header much after the last three rows of the table is shown — that is because by then our eyes would have probably moved away from the table into the content below. This threshold is arbitrary, and it is up to you to decide.

// Calculate allowance
// We allow the last three rows to be shown without the need for the sticky header to remain visible
$t.find('tbody tr:lt(4)').each(function () {
    allowance += $(this).height();
});

Step 5: Fire away, fire away!

Now we are done declaring all the functions we need for the correct styling and positioning of the sticky header. All is left is to bind event handlers to the $(window) object (previously abbreviated as $w for your convenience), and trigger the right function. Here is the game plan:

  1. When the DOM is ready, perform initial round of width calculations
  2. When all resources are loaded, perform second round of width calculations. This is important especially when your table contains resources that are loaded after DOM ready event, such as images, @font-face and more, which will influence how table column widths are computed.
  3. When the parent wrapper is scrolled, but this only happens if the content is overflowing. In the event of a scrolling event is detected, we want to reposition the sticky column
  4. When the viewport is resized, we want to recompute widths and reposition the sticky header
  5. When the window is scrolled, we want to reposition the sticky header

This can be easily summarized with the code below. Do note that the resize and scroll events are debounced and throttled respectively using Bel Alman’s jQuery throttle+debounce plugin.

// #1: When DOM is ready (remember, we have wrapped this entire script in $(function(){...});
setWidths();

// #2: Listen to scrolling event on the parent wrapper (will fire if there is an overflow)
$t.parent('.sticky-wrap').scroll($.throttle(250, function() {
    repositionStickyHead();
    repositionStickyCol();
}));

// Now we bind events to the $(window) object
$w
// #3: When all resources are loaded
.load(setWidths)
// #4: When viewport is resized
// (we debounce this so successive resize event is coalesced into one event)
.resize($.throttle(250, function () {
    setWidths();
    repositionStickyHead();
    repositionStickyCol();
})
// #5: When the window is scrolled
// (we throttled this so scroll event is not fired too often)
.scroll($.throttle(250, repositionStickyHead);

And voila, you’re done!

Discussion

No tutorial is complete without a discussion — be it addressing potential drawbacks on the technicalities of implementation, or the explanation of my strategy in contrary to common expectations.

Why don’t you use position: fixed?

Fixed positioning is a very tantalizing alternative, mainly because of the two advantages it confers:

  1. No calculations are needed for the sticky table header’s vertical offset, because fixed position will cause the element to be positioned absolutely within the viewport and out of the document flow, and
  2. Avoids the stuttering effect of the sticky table header playing catch up, as the scroll event is throttled and therefore the calculations are performed at fixed time intervals and not on the fly. It may appear less responsive to user movement and therefore less natural.

However, the issue with fixed positioning is that we are effectively removing the element from the document flow. In the event when the table width exceeds that of its containing and a horizontal overflow is absolutely necessary, the fixed position header will not scroll with the table because it is detached from the document layout. This is one of the major drawbacks with many jQuery plugins out there that offers sticky table header functionality, and this tutorial was written partially to address this issue.

Why don’t you use position: sticky?

The new position attribute, position: sticky, is only available to the latest version of WebKit browsers and require vendor prefixes. It is not supported in Firefox, Internet Explorer and Opera, therefore risking alienating a huge user base (~95%) due to lack of support. It is not official and standardized as of yet, so I would rather err on the side of caution, and choose a more cumbersome but cross-browser friendly JS-based solution.

Tagged with:

Terry Mun

Amateur photographer, enthusiastic web developer, whimsical writer, recreational cyclist, and PhD student in molecular biology. Sometimes clumsy.

Stay up to date with the latest web design and development news and relevant updates from Codrops.