---
title: 'From Sticky to Pinned: A Stimulus Controller Approach'
author: Victor Cobos
tags: StimulusJS, CSS
published_at: '2025-01-03T22:48:00+01:00'
updated_at: '2025-01-24T22:03:17+01:00'
canonical_url: https://www.dotruby.com/articles/from-sticky-to-pinned-a-stimulus-controller-approach
---

# From Sticky to Pinned: A Stimulus Controller Approach

Sticky elements are a game-changer for modern UIs, but what if you could detect exactly when they get pinned? Using a Stimulus controller, we'll show you how to track sticky behavior with ease. Perfect for adding dynamic flair to your app — don’t miss out on this sticky solution!

## 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.

```js
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 demo](https://www.dotruby.com/demos/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](https://developer.chrome.com/blog/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:

```css
#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.

## Resources

* [Changing CSS as You Scroll with Stimulus](https://railsdesigner.com/change-css-scroll-stimulus/)
