Smart Sticky Header
stimeo--smart-sticky-header
Hides the header on scroll-down and reveals it on scroll-up (with a focus-reveal guarantee).
The stimeo--smart-sticky-header controller adds the direction sense that stimeo--sticky-observer deliberately lacks: scrolling down past offset publishes data-header-hidden="true", any scroll-up (beyond the tolerance jitter guard) or returning above offset reveals it, and focus entering the header always reveals it — a keyboard user tabbing into a hidden header must see where focus went (WCAG 2.4.7 / 2.4.11). The slide-away transform and its reduced-motion opt-out are the consumer's CSS. The scroll source is the window by default; point containerSelector at an overflow container when the header lives inside one (as in this demo).
Scroll down inside the frame to tuck the bar away; scroll up — or Tab into it — to bring it back.
<%# smart-sticky-header: the library only flips data-header-hidden from the scroll
direction (and reveals on focus); the slide-away is this demo's CSS transform.
Scroll down inside the frame to hide the bar, up (or Tab into it) to reveal. %>
<div class="smart-sticky-demo">
<p class="smart-sticky-demo__hint"><%= t("components.smart_sticky_header.demo.hint") %></p>
<div id="smart-sticky-viewport" class="smart-sticky-demo__viewport" role="region" tabindex="0"
aria-label="<%= t('components.smart_sticky_header.demo.viewport_label') %>">
<header class="smart-sticky-demo__header" data-controller="stimeo--smart-sticky-header"
data-stimeo--smart-sticky-header-container-selector-value="#smart-sticky-viewport"
data-stimeo--smart-sticky-header-offset-value="40">
<a href="#smart-sticky-demo-content"><%= t("components.smart_sticky_header.demo.brand") %></a>
</header>
<div id="smart-sticky-demo-content" class="smart-sticky-demo__content">
<% 10.times do |i| %>
<p><%= t("components.smart_sticky_header.demo.paragraph", index: i + 1) %></p>
<% end %>
</div>
</div>
</div>
/*
* Presentation-only styles for the smart-sticky-header demo. The library flips
* data-header-hidden; the slide-away transform (and its reduced-motion opt-out)
* lives here. The controller is pointed at the demo's own scroll frame via
* containerSelector (the catalog page itself does not scroll).
*/
.smart-sticky-demo__hint {
margin: 0 0 0.75rem;
color: var(--muted);
}
.smart-sticky-demo__viewport {
position: relative;
height: 16rem;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 0.5rem;
}
.smart-sticky-demo__viewport:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.smart-sticky-demo__header {
position: sticky;
top: 0;
padding: 0.75rem 1rem;
background: var(--bg);
border-bottom: 1px solid var(--border);
transition: transform 0.2s ease;
}
.smart-sticky-demo__header[data-header-hidden="true"] {
transform: translateY(-100%);
}
@media (prefers-reduced-motion: reduce) {
.smart-sticky-demo__header {
transition: none;
}
}
.smart-sticky-demo__content {
padding: 1rem;
}
.smart-sticky-demo__content p {
margin: 0 0 1.25rem;
color: var(--fg);
}
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--smart-sticky-header"
Values
| Name | Description | Attribute |
|---|---|---|
containerSelector
|
Selector for the scroll source; empty (default) means the window. | data-stimeo--smart-sticky-header-container-selector-value |
offset
|
Never hide within this many px of the top. Default 80. | data-stimeo--smart-sticky-header-offset-value |
tolerance
|
Ignore scroll deltas below this (jitter guard). Default 4. | data-stimeo--smart-sticky-header-tolerance-value |
Events
| Name | Description | Event |
|---|---|---|
change
|
Fires with { hidden } on state transitions only. |
stimeo--smart-sticky-header: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-header-hidden |
Controller element | "true" (tucked away) / "false" (shown) — the consumer's CSS translates it off-screen. |