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.
4 comments on “Stimulus.js Tutorial: Listening to onScroll Events for a Sticky Table Header”
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.
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.
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.
ok that makes sense! (I had omitted the user of resizeHeader which meant that the onScrollRunning boolean would never have been tripped).