This article was originally published on Rails Designer
Tweaking the UI element or component based on some scroll state, can help make it stand out or guide focus from the user.
I recently had to add such a feature where a, potential, long list of items could scroll below the navigation's leader element. If the list “touched” the leader, extra CSS classes would be added, making sure it would still be eligible with the items scrolled below it. Something like this:
Typically this would a case for JS' MutationObserver, but since the scrolling is tied to the SidebarNavigationComponent and not the body, it cannot be used and a slight reinventing of the wheel is needed. It will result in a small, but reusable Stimulus controller. Ready to be copied and pasted into your app. ♻️
Let's go over the required HTML first!
<nav data-controller="intersect" data-intersect-intersecting-class="bg-white">
<div data-intersect-target="trigger">
Spinal Builder
</div>
<ul data-intersect-target="observed">
<li>
<a href="https://spinalbuilder.com/">Dashboard</a>
</li>
<!-- etc. -->
</ul>
</nav>
All simple enough, right? Now the intersect_controller.js.
// app/javascript/controller/intersect_controller.js
import { Controller } from "@hotwired/stimulus";
export default class extends Controller {
static targets = ["trigger", "observed"];
static classes = ["intersecting"];
static values = {touching: { type: Boolean, default: false }};
initialize() {
this.checkPosition = this.#checkPosition.bind(this);
}
connect() {
this.element.addEventListener("scroll", this.#onScroll.bind(this));
this.checkPosition;
}
}
This sets up all the plumbing needed. It binds the checkPosition()
method in the initializer to the controller instance for consistent this context. Once connected, it adds a scroll event listener to the controller's element (eg. nav HTML-element). Then immediately calls checkPosition
initialized earlier.
Let's add the called private functions #checkPosition and #onScroll.
export default class extends Controller {
// …
// private
#onScroll() {
if (!this.touchingValue) {
window.requestAnimationFrame(() => {
this.checkPosition();
this.touchingValue = false;
})
this.touchingValue = true;
}
}
#checkPosition() {
const observedRect = this.observedTarget.getBoundingClientRect();
const triggerRect = this.triggerTarget.getBoundingClientRect();
const navRect = this.element.getBoundingClientRect();
const relativeObservedTop = observedRect.top - navRect.top;
const relativeTriggerBottom = triggerRect.bottom - navRect.top;
if (relativeObservedTop > relativeTriggerBottom) {
this.triggerTarget.classList.remove(...this.intersectingClasses);
} else {
this.triggerTarget.classList.add(...this.intersectingClasses);
}
}
}
The #onScroll function uses requestAnimationFrame
to optimize scroll performance, so checkPosition is called efficiently and not make your browser turn on your laptop's fans. 🔥 It then sets the touchingValue to true or false. The #checkPosition function compares the positions of observed and trigger target-elements relative to the controller's element, adding or removing the defined intersecting class(es) based on their intersection.
Want to be more comfortable with JavaScript. Maybe make it your second-favorite language? Check out JavaScript for Rails Developers.
Now all that is left, is to be good citizens and remove the scroll event listener once the element is removed from the DOM.
export default class extends Controller {
// …
disconnect() {
this.element.removeEventListener("scroll", this.#onScroll);
}
// …
}
And there you have it. You can now reuse this controller for other elements too by changing the trigger and observed targets and the intersecting classes.
Top comments (1)
How would you use this controller? 🫵