DEV Community

Cover image for Changing CSS as You Scroll with Stimulus
Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Changing CSS as You Scroll with Stimulus

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:

Preview of the end result of thi

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>
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

  // …
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
railsdesigner profile image
Rails Designer

How would you use this controller? 🫵