Sticky State Observer
stimeo--sticky-observer
Tells you the moment a sticky header actually sticks, ready to trigger its shadow.
Watches whether an element that sticks to the top has actually stuck. The moment it does is readable from CSS, so you can drop a shadow or shrink its height. It works by watching a small marker placed just before the sticky element and seeing when it passes the top. It works both for the page and inside a box that scrolls, and the line it measures against can be offset to match how far down the element sticks. This is a signal for presentation and carries no meaning for screen readers. Making it stick, and the shadow, are your CSS.
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.
Keyboard
This component has no keyboard interactions of its own.
<%# 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">
<%# The scroll box is a labelled, focusable region so it can be scrolled with the
keyboard alone (arrows / PageDown) in every browser, not only the ones that
make scrollers focusable by themselves. %>
<div class="sticky-demo__scroll" role="region" tabindex="0"
aria-label="<%= t('components.sticky_observer.demo.viewport_label') %>">
<%# 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;
}
/* The scroll box takes focus (tabindex="0") so it can be scrolled by keyboard. */
.sticky-demo__scroll:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.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);
/* Layer the tint (translucent in dark mode) over the opaque card so the sticky header
never lets scrolling content show through. */
background:
linear-gradient(var(--color-primary-soft), var(--color-primary-soft)), var(--surface-card);
}
.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. |