From Sticky to Pinned: A Stimulus Controller Approach.

Published on January 03, 2025
Written by Victor Cobos

Sticky Elements: The Why and the Wow Factor

Sticky positioning is a powerful CSS feature that lets elements remain fixed within their parent container as users scroll, creating engaging and intuitive interfaces. Think of sticky headers, sidebars, or progress indicators—these elements enhance usability and keep important content within reach.

But wouldn't it be great to know precisely when a sticky element transitions between states, from its initial position to being stuck? While a proposed :stuck pseudo-class could simplify this detection in the future, it's not currently supported. That's where creative solutions come into play. By detecting these transitions programmatically, you can unlock dynamic interactions like updating styles, triggering animations, or even tracking analytics events.

In this article, we'll explore how to achieve this using a Stimulus controller, making your web applications even more dynamic and user-friendly.

Detecting Stuck States with a Stimulus Controller

Now that we understand the importance of detecting sticky element transitions, let's dive into implementing a solution. Below is a Stimulus controller that uses the IntersectionObserver API to determine when a sticky element transitions between stuck and unstuck states.

import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static values = {
    root: String,
    rootMargin: { type: String, default: '-1px 0px 0px 0px' },
    stuckClass: { type: String, default: '--stuck' },
    dispatchEvents: { type: Boolean, default: true }
  }

  connect () {
    this.observer = new IntersectionObserver(this._handleIntersect.bind(this), this.observerOptions)
    this.observer.observe(this.element)
  }

  disconnect () {
    this.observer.disconnect()
  }

  get observerOptions () {
    return {
      root: this.hasRootValue ? document.querySelector(this.rootValue) : null,
      rootMargin: this.rootMarginValue,
      threshold: 1.0
    }
  }

  _handleIntersect (entries) {
    entries.forEach((entry) => {
      const isStuck = !entry.isIntersecting
      this._toggleStuckState(isStuck)
    })
  }

  _toggleStuckState (isStuck) {
    this.element.classList.toggle(this.stuckClassValue, isStuck)
    if (this.dispatchEventsValue) {
      const eventName = isStuck ? 'stuck' : 'unstuck'
      this.element.dispatchEvent(new CustomEvent(eventName, { bubbles: true }))
    }
  }
}

One key element in this implementation is the rootMargin option in the IntersectionObserver configuration. This parameter allows us to adjust the margin around the root elemen — essentially defining a boundary where the observer triggers.

In this example, the rootMargin is set to -1px 0px 0px 0px. This means that the observer will trigger when the sticky element is just 1px away from intersecting the top edge of the viewport (or the custom root element if specified). This subtle offset ensures that we accurately capture the moment the sticky element becomes stuck.

Without this adjustment, the observer might not trigger precisely at the right time, leading to a less accurate detection of the sticky state. By carefully tuning rootMargin, we can create a seamless and reliable way to detect when the element transitions between states.

To better understand how this works in action, here’s a small interactive demo:

Sticky

Conclusion

Detecting when a sticky element transitions into its pinned state can open up a world of possibilities for dynamic and engaging user interfaces. By combining the power of CSS's position: sticky with the flexibility of a Stimulus controller and the IntersectionObserver API, we've created a simple yet powerful solution that's both reusable and customizable.

This approach not only allows you to style sticky elements dynamically but also provides the ability to trigger events like animations or analytics tracking, making your application more interactive and responsive to user behavior.

While the potential :stuck pseudo-class could make this process even more straightforward in the future, using a Stimulus controller gives you full control right now. So, whether it's for visual flair or functional enhancements, adding this capability to your app can significantly elevate the user experience.

Don't get stuck on this example, though — take it further! Share your ideas and sticky experiments, and let's keep creating dynamic, user-friendly interfaces that truly stick with our users.

Bonus Track: Exploring CSS scroll-state() for Sticky Elements

The CSS scroll-state() function is a new feature introduced in Chrome 133 Beta that allows developers to style elements based on their scroll state directly in CSS. This means you can apply styles to elements when they reach specific scrolling conditions, such as being stuck due to position: sticky 🙂

By defining an element as a scroll state container using container-type: scroll-state, you can use container queries to dynamically adjust styles for its children or itself based on its scroll-related state. For example:

#sticky {
  position: sticky;
  container-type: scroll-state;
}

@container scroll-state(stuck: top) {
  #sticky-child {
    font-size: 75%;
  }
}

This feature provides a declarative, lightweight alternative to JavaScript for styling sticky or scroll-dependent elements, making it easier to create dynamic, responsive designs.

Subscribe to get future articles via the RSS feed .