Changing CSS as You Scroll with Stimulus

A 3D rendering of a mouse in a maze

Tweaking the UI element or component based on the scroll state, can help make it stand out or guide focus to 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:

Preview of the end result of this article

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);
    }
  }
}

Want to be comfortable writing and reading JavaScript like this? Maybe even make it your second-favorite language? Check out JavaScript for Rails Developers!

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.

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.

How would you use this controller?

Get UI & Product Engineering Insights for Rails Apps (and product updates!)

Published at . Have suggestions or improvements on this content? Do reach out.

UI components for Ruby on Rails apps

$ 129 one-time
payment

Get Access
  • One-time Payment

  • Access to the Entire Library

  • Built for Ruby on Rails (inc. Rails 8)

  • Designed with Tailwind CSS and Enhanced with Hotwire

  • Updates for 12 months