Magic Responsive Tables with Stimulus and IntersectionObserver
This is a guest collaboration with Pascal Laliberté, author of Modest JS Works, a short online book for those who want to write modest JavaScript, and then focus on all the other stuff that matters in building an app.
You’re working on this data table for your app. It’s mostly server-side HTML. Nothing fancy.
But as you’re adding columns, you’ve got a problem. How are you going to handle small screens?
The table has to scroll horizontally to let the user see all the columns. The table needs to become “responsive”.
In this article, we’ll look at a side-scrolling widget used in Shopify’s Polaris UI toolkit (currently built in React), and we’ll recreate the functionality using just Stimulus without having to rewrite your data table in React.
And instead of adding resize watchers and scroll watchers like the original React component uses, we’ll be using the IntersectionObserver API, a new browser feature that’s widely available.
Quick Intro to Stimulus
Stimulus a small library that helps you add sprinkles of progressive interactivity to your existing HTML.
Just as CSS adds styling to elements as they appear in the Document Object Model (DOM), Stimulus adds interactivity (event handlers, actions, etc.) to elements as they appear in the DOM too (and removes it when they’re removed from the DOM). We’ll be using it here because it pairs so well with Rails, and with server-side rendered HTML.
And just like you can tie styling by adding CSS classes to your HTML, you can tie interactivity by adding special Stimulus data-
attributes to elements. Stimulus watches for those, and when there’s a match, it fires up its interactivity (matching a Stimulus “controller” here named table-scroll
).
<div data-controller="table-scroll">
<button
class="button button-scroll-right"
data-table-scroll-target="scrollRightButton"
data-action="table-scroll#scrollRight"
>
...
</button>
</div>
Re-creating the Scrolling Nav from Shopify Polaris Data Tables
Shopify’s UI library introduces a clever side-scrolling navigation widget that is only shown when there are more columns than can fit in the context. There are Buttons to scroll left and right and small dots to show how many columns are in view.
While the original is in React, we’ll recreate the functionality using Stimulus. The HTML here is from Shopify’s implementation: if you strip all of the Polaris classes, you’ll have the structure to style it to fit your own application’s styles.
So let’s start by creating the overall markup structure you’ll be coding in your application and attaching the table-scroll
Stimulus controller.
(Please note that some CSS styles have been omitted for brevity, I’ve tried to call out the critical classes where possible.)
<div data-controller="table-scroll">
<div data-table-scroll-target="navBar">
<!-- Navigation widget -->
</div>
<div class="flex flex-col mx-auto">
<div class="overflow-x-auto" data-table-scroll-target="scrollArea">
<table class="min-w-full">
<!-- Table contents -->
</table>
</div>
</div>
</div>
Next let’s set up the targets for each column by adding an attribute to the <th>
tags. We can take advantage of Stimulus’ multiple target binding by setting all of the columns to a target value of column
, which will allow us to automatically bind a columnTargets
array in our Stimulus controller.
<!-- Table contents -->
<table class="min-w-full">
<thead>
<tr>
<th data-table-scroll-target="column">Product</th>
<th data-table-scroll-target="column">Price</th>
<th data-table-scroll-target="column">SKU</th>
<th data-table-scroll-target="column">Sold</th>
<th data-table-scroll-target="column">Net Sales</th>
</tr>
</thead>
<tbody>
<!-- Table body -->
</tbody>
</table>
Next let’s build the markup for the navigation widget. We will use a dot icon for each of the columns and a left and right arrow to scroll the table.
<!-- Navigation widget -->
<div data-table-scroll-target="navBar">
<!-- Left button -->
<button data-table-scroll-target="leftButton" data-action="table-scroll#scrollLeft">
<svg></svg>
</button>
<!-- Column visibility dots -->
<% 5.times do %>
<span class="text-gray-200" data-table-scroll-target="columnVisibilityIndicator">
<svg></svg>
</span>
<% end %>
<!-- Scroll Right button -->
<button data-table-scroll-target="rightButton" data-action="table-scroll#scrollRight">
<svg></svg>
</button>
</div>
And lastly, let’s pass in some class data to define CSS styles to apply when the navigation widget should be shown or hidden and how the buttons and dots should be styled. You may choose to hard-code these classes into the Stimulus controller, but you may want to make them configurable depending on the needs of your project (for example, you may want to use this controller with multiple tables but use a different color to indicate the visible columns).
<div
data-controller="table-scroll"
data-table-scroll-nav-shown-class="flex"
data-table-scroll-nav-hidden-class="hidden"
data-table-scroll-button-disabled-class="text-gray-200"
data-table-scroll-indicator-visible-class="text-blue-600"
>
<!-- The rest of the markup -->
</div>
Using IntersectionObserver to bring it to life
Now that we’ve annotated the markup, we can add the Stimulus controller.
We’ll need some way of watching the scrollArea
position and detecting what is visible. Unlike the Polaris implementation, we’ll use the IntersectionObserver
API. No need for window.resize
or window.scroll
, which are more costly on performance than the new native IntersectionObserver
browser API.
The IntersectionObserver
API watches the visibility of elements, and a callback is fired on visibility changes. In our case, we’ll be watching the visibility of column headings.
// controllers/table_scroll_controller.js
import { Controller } from "stimulus";
export default class extends Controller {
static targets = [
"navBar",
"scrollArea",
"column",
"leftButton",
"rightButton",
"columnVisibilityIndicator",
];
static classes = [
"navShown",
"navHidden",
"buttonDisabled",
"indicatorVisible",
];
connect() {
// start watching the scrollAreaTarget via IntersectionObserver
}
disconnect() {
// stop watching the scrollAreaTarget, teardown event handlers
}
}
Since we’re progressively enhancing the page with Stimulus, we should take care to check if the browser supports IntersectionObserver
and degrade gracefully if not.
When the controller is connected, we create an IntersectionObserver
and provide a callback and then register that we want to observe all of our columnTargets
.
Each time the updateScrollNavigation
callback is fired, (which also fires by default when intersectionObserver is initialized), we’ll update each column heading’s data-is-visible
attribute, to be checked later by the other callbacks.
import { Controller } from "stimulus";
function supportsIntersectionObserver() {
return (
"IntersectionObserver" in window ||
"IntersectionObserverEntry" in window ||
"intersectionRatio" in window.IntersectionObserverEntry.prototype
);
}
export default class extends Controller {
static targets = [ ... ];
static classes = [ ... ];
connect() {
this.startObservingColumnVisibility();
}
startObservingColumnVisibility() {
if (!supportsIntersectionObserver()) {
console.warn(`This browser doesn't support IntersectionObserver`);
return;
}
this.intersectionObserver = new IntersectionObserver(
this.updateScrollNavigation.bind(this),
{
root: this.scrollAreaTarget,
threshold: 0.99, // otherwise, the right-most column sometimes won't be considered visible in some browsers, rounding errors, etc.
}
);
this.columnTargets.forEach((headingEl) => {
this.intersectionObserver.observe(headingEl);
});
}
updateScrollNavigation(observerRecords) {
observerRecords.forEach((record) => {
record.target.dataset.isVisible = record.isIntersecting;
});
this.toggleScrollNavigationVisibility();
this.updateColumnVisibilityIndicators();
this.updateLeftRightButtonAffordance();
}
disconnect() {
this.stopObservingColumnVisibility();
}
stopObservingColumnVisibility() {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
}
There is a bit of code to set up and register things, but it’s fairly straightforward and from here, the remaining work is to sync the visibility of the columns with the navigation widget.
You can see that we use the target binding in Stimulus to toggle on and off CSS classes in the page. And since we made the CSS class configurable, you can tweak the UI by editing the HTML, not rebuilding your JavaScript bundle.
toggleScrollNavigationVisibility() {
const allColumnsVisible =
this.columnTargets.length > 0 &&
this.columnTargets[0].dataset.isVisible === "true" &&
this.columnTargets[this.columnTargets.length - 1].dataset.isVisible ===
"true";
if (allColumnsVisible) {
this.navBarTarget.classList.remove(this.navShownClass);
this.navBarTarget.classList.add(this.navHiddenClass);
} else {
this.navBarTarget.classList.add(this.navShownClass);
this.navBarTarget.classList.remove(this.navHiddenClass);
}
}
updateColumnVisibilityIndicators() {
this.columnTargets.forEach((headingEl, index) => {
const indicator = this.columnVisibilityIndicatorTargets[index];
if (indicator) {
indicator.classList.toggle(
this.indicatorVisibleClass,
headingEl.dataset.isVisible === "true"
);
}
});
}
updateLeftRightButtonAffordance() {
const firstColumnHeading = this.columnTargets[0];
const lastColumnHeading = this.columnTargets[this.columnTargets.length - 1];
this.updateButtonAffordance(
this.leftButtonTarget,
firstColumnHeading.dataset.isVisible === "true"
);
this.updateButtonAffordance(
this.rightButtonTarget,
lastColumnHeading.dataset.isVisible === "true"
);
}
updateButtonAffordance(button, isDisabled) {
if (isDisabled) {
button.setAttribute("disabled", "");
button.classList.add(this.buttonDisabledClass);
} else {
button.removeAttribute("disabled");
button.classList.remove(this.buttonDisabledClass);
}
}
Lastly, we need to add the actions that are triggered when clicking the navigation buttons. When the buttons are clicked, we find the next non-visible column in the scrolling direction and then scroll the table so the leading edge of the column.
scrollLeft() {
// scroll to make visible the first non-fully-visible column to the left of the scroll area
let columnToScrollTo = null;
for (let i = 0; i < this.columnTargets.length; i++) {
const column = this.columnTargets[i];
if (columnToScrollTo !== null && column.dataset.isVisible === "true") {
break;
}
if (column.dataset.isVisible === "false") {
columnToScrollTo = column;
}
}
this.scrollAreaTarget.scroll(columnToScrollTo.offsetLeft, 0);
}
scrollRight() {
// scroll to make visible the first non-fully-visible column to the right of the scroll area
let columnToScrollTo = null;
for (let i = this.columnTargets.length - 1; i >= 0; i--) {
// right to left
const column = this.columnTargets[i];
if (columnToScrollTo !== null && column.dataset.isVisible === "true") {
break;
}
if (column.dataset.isVisible === "false") {
columnToScrollTo = column;
}
}
this.scrollAreaTarget.scroll(columnToScrollTo.offsetLeft, 0);
}
You can view the full code via this gist or play with an interactive example via this Codepen
Wrap it up
And voila! We’ve got a pretty nifty responsive scrolling table. On large screens, it looks like a normal HTML table. But as you shrink the view port, the navigation widget appears and you can see the dots helping to show what part of the table is visible.
Overall, this controller comes in at under 200 lines of code and should be able to handle various sized tables throughout your app.
With the release of Hotwire, Stimulus is an important piece for the “last mile” of interactivity in non-SPA apps. While Stimulus is often used for running small bits of JavaScript, you can build out more robust controllers that mirror fully featured UI libraries.
Before you completely change your application architecture to use a fancy client-side framework, see if you can get by with your existing HTML markup and a bit of Stimulus.
Was this article valuable? Subscribe to the low-volume, high-signal newsletter. No spam. All killer, no filler.