Sticky State Observer
stimeo--sticky-observer
Detects whether a position: sticky element is stuck and exposes it as data-stuck.
The stimeo--sticky-observer controller is a pure state-detection utility with no APG widget. Using an IntersectionObserver and a sentinel placed just before the sticky element, it detects when the sentinel scrolls out past the top of the viewport (or a rootSelector container) and sets data-stuck="true" on the sticky element (false otherwise), dispatching stimeo--sticky-observer:change on each transition. data-stuck is a visual hook only — it carries no ARIA role or state. offset feeds a negative top rootMargin and must match the sticky element's CSS top. Behavior only — position: sticky, shadows, and shrink effects are owned by this Playground; the observer is disconnected on disconnect (Turbo included).
Scrollable content paragraph 1. Scroll inside the box to make the heading stick.
Scrollable content paragraph 2. Scroll inside the box to make the heading stick.
Scrollable content paragraph 3. Scroll inside the box to make the heading stick.
Scrollable content paragraph 4. Scroll inside the box to make the heading stick.
Scrollable content paragraph 5. Scroll inside the box to make the heading stick.
Scrollable content paragraph 6. Scroll inside the box to make the heading stick.
Scrollable content paragraph 7. Scroll inside the box to make the heading stick.
Scrollable content paragraph 8. Scroll inside the box to make the heading stick.
<%# Markup for the sticky-observer (stuck-state detection) demo.
The library watches a sentinel's intersection with IntersectionObserver and syncs
data-stuck="true" once the sticky element actually sticks. position: sticky and the
shadow rendering are owned by demo.css. rootSelector specifies the scroll parent
(this demo frame). %>
<div class="sticky-demo" data-controller="stimeo--sticky-observer"
data-stimeo--sticky-observer-root-selector-value=".sticky-demo__scroll">
<div class="sticky-demo__scroll">
<%# Place the sentinel right before the sticky element. A 1px non-zero height keeps
intersection detection stable. %>
<div class="sticky-demo__sentinel" aria-hidden="true"
data-stimeo--sticky-observer-target="sentinel"></div>
<header class="sticky-demo__header" data-stimeo--sticky-observer-target="element">
<%= t("components.sticky_observer.demo.header") %>
</header>
<div class="sticky-demo__body">
<% 8.times do |i| %>
<p><%= t("components.sticky_observer.demo.paragraph", number: i + 1) %></p>
<% end %>
</div>
</div>
</div>
/* Presentation CSS for sticky-observer. The library only syncs data-stuck, so
position: sticky and the "show a shadow once stuck" look are drawn here by reading data-stuck. */
.sticky-demo {
max-width: 460px;
}
/* The scroll parent (the element rootSelector points to). Stickiness is judged relative to it. */
.sticky-demo__scroll {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 8px;
position: relative;
}
.sticky-demo__sentinel {
height: 1px;
}
/* position: sticky is owned by the consumer's CSS; data-stuck is set by the library. */
.sticky-demo__header {
position: sticky;
top: 0;
padding: 0.75rem 1rem;
background: var(--surface-card);
font-weight: 600;
color: var(--color-text);
border-bottom: 1px solid var(--border-default);
transition: box-shadow 0.2s ease, background 0.2s ease;
}
/* Once stuck, show a shadow to convey that it's "floating". */
.sticky-demo__header[data-stuck="true"] {
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.18);
background: var(--color-primary-soft);
}
.sticky-demo__body {
padding: 0 1rem 1rem;
}
.sticky-demo__body p {
color: var(--color-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.sticky-demo__header {
transition: none;
}
}
This demo needs no consumer-side JS (the controller handles the behavior).
These demo styles use shared design tokens (light + dark). Copy the shared styles too, then toggle data-theme on your root element for dark mode.
The data-* attributes you add to your own HTML to wire this component. Put the data-controller below on a root element, then place its targets / values / actions inside that element.
On the root element
data-controller="stimeo--sticky-observer"
Targets
| Name | Description | Attribute |
|---|---|---|
sentinel
required
|
A zero-height marker before the sticky element whose intersection signals the stuck state. | data-stimeo--sticky-observer-target="sentinel" |
element
required
|
The position: sticky element on which data-stuck is reflected. |
data-stimeo--sticky-observer-target="element" |
Values
| Name | Description | Attribute |
|---|---|---|
rootSelector
|
An optional selector for the scroll root container; defaults to the viewport. | data-stimeo--sticky-observer-root-selector-value |
offset
|
A px top inset fed to the observer's rootMargin; should match the sticky element's CSS top (default 0). |
data-stimeo--sticky-observer-offset-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Dispatched on each stuck-state transition; detail carries the stuck boolean. | stimeo--sticky-observer:change |
State hooks
The library only manages these ARIA/data attributes and custom properties. Your CSS reads them to render the look — selectors like [aria-selected], [aria-expanded], or var(--stimeo--…) hook into this state.
| Hook | Target | Meaning |
|---|---|---|
data-stuck |
Sticky element | "true" while stuck / "false" otherwise. The trigger for shadows or shrink. |