Skip to content →

Stimulus.js Tutorial: Listening to onScroll Events for a Sticky Table Header

In one of my projects, I had a table with a lot of rows of uniform data. I started using a JQuery plugin called Sticky Table Headers. It worked really well, but I’ve been experimenting with moving different components of my code to Stimulus, where it made sense, so I thought I’d try to replicate a similar feature using just a stimulus controller.

Our HTML structure needs to have a table with a child thead. The thead element is then searched for TH tags. Aside from the stimulus data attributes, we don’t need anything extra.

The data attributes are for the controller, setting the table as a target that we’ll reference in the controller, and the event listeners:

<table data-controller="sticky-header" 

The controller, sticky_header_controller.js, will listen for scroll events. These scroll events will dictate how the header is positioned on the page.

Let’s set up the Stimulus controller, and make sure our tableTarget is there:

import { Controller } from "stimulus"

export default class extends Controller {
  static targets = [ "table" ]

  // Functions  will be added here


Our connect function is going to perform the setup for the event handlers:

connect() {
  this.originalDimensions = this.tableTarget.getBoundingClientRect()
  this.tableHeader = this.tableTarget.tHead;
  this.onScrollRunning = true

Our onScroll function will handle the scroll events, and attempt to schedule the actual computations. Scheduling the actual work keeps scrolling smooth, and allows the browser to pick the best time to actually change the header position.

onScroll(event) {
  if (!this.onScrollRunning) {
    this.onScrollRunning = true;
    if (window.requestAnimationFrame) {
    } else {
       setTimeout(this.scrollTableHeader.bind(this), 66);

scrollTableHeader() {
  if (window.scrollY >= {
    this.placeholder.setAttribute("style", "opacity: 0;")
    this.width = this.placeholder.getBoundingClientRect().width
    this.tableHeader.setAttribute("style", "top: 0px; position: fixed; margin-top: 0px; z-index: 3; width: " + this.width + "px;")
  } else  { 
    // Reset Style
    this.placeholder.setAttribute("style", "display: none; opacity: 0;")
    this.tableHeader.setAttribute("style", "")
  this.onScrollRunning = false

The resizeHeader function applies width styling to each <th> element so that the header width doesn’t collapse when it’s position is set to fixed. It then clones that header, and sets the invisible clone in the table. This thead placeholder prevents the table from jumping up and down as scrolling occurs.

resizeHeader() {
  this.tableHeader.childNodes.forEach((el, i) => {
    el.childNodes.forEach((childEl, i) => {
      if (childEl.nodeName == "TH") {
        var style = window.getComputedStyle(childEl)
        let buffer = parseFloat(style.paddingRight) 
                               + parseFloat(style.paddingLeft) 
                               + parseFloat(style.borderRightWidth) 
                               + parseFloat(style.borderLeftWidth) = (childEl.getBoundingClientRect().width - buffer) + "px"

  if (this.placeholder) {
    this.width = this.placeholder.getBoundingClientRect().width
  } else {
    this.width = this.tableHeader.getBoundingClientRect().width
  this.placeholder = this.tableHeader.cloneNode(true)
  this.placeholder.setAttribute("data-target", "")
  this.placeholder.setAttribute("style", "display: none; opacity: 0;")
  this.tableHeader.insertAdjacentElement('afterend', this.placeholder);

Now you have a nice way to display a large set of data in a table, like stock prices, and not have to worry about which column is which as you’re scrolling through the page.

Want To Learn More?

Try out some more of my Stimulus.js Tutorials.

Excited for Stimulus?

Drop your email below, and you won’t miss the next Stimulus Tutorial

Published in ruby on rails Stimulus JS

Comments are closed.