Skip to content

Blogging On Rails

Everything on Rails!

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" 
        data-target="sticky-header.table" 
        data-action="scroll@window->sticky-header#onScroll">

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
  this.resizeHeader()
}

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) {
      window.requestAnimationFrame(this.scrollTableHeader.bind(this));
    } else {
       setTimeout(this.scrollTableHeader.bind(this), 66);
    }
   }
}

scrollTableHeader() {
  if (window.scrollY >= this.originalDimensions.top) {
    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.style.width = (childEl.getBoundingClientRect().width - buffer) + "px"
      }
    })
  })

  if (this.placeholder) {
    this.width = this.placeholder.getBoundingClientRect().width
    this.placeholder.remove()
  } 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);
    
  this.scrollTableHeader()
}

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.

Feel free to leave comments or questions below.

Want To Learn More?

Try out some more of my Stimulus.js Tutorials.

Make Interactivity Default 

Make your web app interactive now with easy to implement and simple to add HOTWire integrations. 

Enter your email and get a free sample of my HOTWire Tutorials ebook.

We won’t send you spam. Unsubscribe at any time.

4 comments on “Stimulus.js Tutorial: Listening to onScroll Events for a Sticky Table Header”

  1. John,

    Thanks for this — super helpful.

    If you add a second action to the data-action attribute (resize@window->sticky-header#resizeHeader), then your Stimulus controller also handles horizontal resizing.

    So, the full attribute would be:

    data-action=”scroll@window->sticky-header#onScroll resize@window->sticky-header#resizeHeader”

    Thanks — this helped immeasurably.

  2. Thank you for the pointers John. I utilised your code for a similar yet slightly different function.

    As an aside: something I suspect might be a typo:

    In your connect() function you have set the following to true:

    this.onScrollRunning = true

    I suspect that it should be false. i.e.

    this.onScrollRunning = false

    otherwise you would never be able to enter the onScroll event handler!

    Chrs again for the pointers.

    1. It doesn’t cause an issue in this example because connect also calls this.resizeHeader(), which will set this.onScrollRunning to false when it’s done.

      1. ok that makes sense! (I had omitted the user of resizeHeader which meant that the onScrollRunning boolean would never have been tripped).

Leave a Reply

Your email address will not be published. Required fields are marked *