Scroll Restore
stimeo--scroll-restore
Persists and restores an inner scroll region's position across Turbo navigations.
The stimeo--scroll-restore controller has no APG widget pattern — it is a pure state-preservation utility. Turbo swaps the whole body on navigation, so an inner scroll container is rebuilt with scrollTop reset to 0. Rather than hand-write a controller for this in every app, it saves the offset under a stable key in sessionStorage and restores it on connect, so it survives both Turbo Drive navigations and full reloads within the tab session. Keying by key (falling back to the element id) makes it multi-instance safe. The scroll listener is internal and passive, coalesced through requestAnimationFrame, and flushes synchronously on disconnect (Turbo included). axis selects vertical / horizontal / both. It sets no ARIA/CSS and never moves focus.
Scroll both lists, then press Tear down & rebuild. The left list (with stimeo--scroll-restore) returns to where you were; the plain right list jumps back to the top — exactly what happens to an inner scroll region on a Turbo navigation. The position also survives a real page navigation and a browser reload (within the tab session).
With Scroll Restore
- Row 1
- Row 2
- Row 3
- Row 4
- Row 5
- Row 6
- Row 7
- Row 8
- Row 9
- Row 10
- Row 11
- Row 12
- Row 13
- Row 14
- Row 15
- Row 16
- Row 17
- Row 18
- Row 19
- Row 20
Without (plain)
- Row 1
- Row 2
- Row 3
- Row 4
- Row 5
- Row 6
- Row 7
- Row 8
- Row 9
- Row 10
- Row 11
- Row 12
- Row 13
- Row 14
- Row 15
- Row 16
- Row 17
- Row 18
- Row 19
- Row 20
<%# Markup for the scroll-restore (scroll position persistence) demo.
The behavior shines when the DOM is torn down and rebuilt — exactly what Turbo
does on navigation. To make that observable without leaving the page, the
"rebuild" button removes the two scroll regions and re-inserts them (demo.js).
The left region uses stimeo--scroll-restore (its offset is saved to
sessionStorage and restored on reconnect); the right one has no controller and
snaps back to the top, so the difference is obvious. It is behavior only — the
library sets no ARIA/CSS and never moves focus; the box look is demo.css's. %>
<div class="scroll-restore-demo" data-scroll-restore-demo>
<p><%= t("components.scroll_restore.demo.hint") %></p>
<div class="scroll-restore-demo__toolbar">
<button type="button" class="scroll-restore-demo__rebuild" data-scroll-restore-remount>
<%= t("components.scroll_restore.demo.rebuild") %>
</button>
</div>
<%# demo.js captures this slot's HTML and swaps it out/in to fire the controller's
disconnect (save) → connect (restore) cycle, mirroring a Turbo navigation. %>
<div class="scroll-restore-demo__cols" data-scroll-restore-slot>
<div class="scroll-restore-demo__col">
<p class="scroll-restore-demo__label"><%= t(
"components.scroll_restore.demo.with_label"
) %></p>
<div
class="scroll-restore-demo__box"
data-controller="stimeo--scroll-restore"
data-stimeo--scroll-restore-key-value="catalog-demo">
<ol class="scroll-restore-demo__list">
<% (1..20).each do |n| %>
<li class="scroll-restore-demo__row"><%= t(
"components.scroll_restore.demo.row", number: n
) %></li>
<% end %>
</ol>
</div>
</div>
<div class="scroll-restore-demo__col">
<p class="scroll-restore-demo__label"><%= t(
"components.scroll_restore.demo.without_label"
) %></p>
<div class="scroll-restore-demo__box">
<ol class="scroll-restore-demo__list">
<% (1..20).each do |n| %>
<li class="scroll-restore-demo__row"><%= t(
"components.scroll_restore.demo.row", number: n
) %></li>
<% end %>
</ol>
</div>
</div>
</div>
</div>
/* Presentation CSS for scroll-restore. The library only saves/restores each box's
scroll offset (sessionStorage); the toolbar, columns, and list look live here. */
.scroll-restore-demo {
color: var(--color-text-muted);
}
.scroll-restore-demo__toolbar {
margin: 0.75rem 0;
}
.scroll-restore-demo__rebuild {
padding: 0.45rem 0.9rem;
font: inherit;
cursor: pointer;
border: 1px solid var(--border-interactive);
border-radius: 0.375rem;
background: var(--surface-card);
}
.scroll-restore-demo__rebuild:hover {
background: var(--surface-subtle);
}
.scroll-restore-demo__rebuild:focus-visible {
outline: 2px solid var(--color-primary-hover);
outline-offset: 2px;
}
.scroll-restore-demo__cols {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.scroll-restore-demo__col {
flex: 1 1 12rem;
min-width: 0;
}
.scroll-restore-demo__label {
margin: 0 0 0.35rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
}
.scroll-restore-demo__box {
height: 11rem;
overflow-y: auto;
border: 1px solid var(--border-strong);
border-radius: 0.5rem;
background: var(--surface-card);
}
.scroll-restore-demo__list {
margin: 0;
padding: 0;
list-style: none;
}
.scroll-restore-demo__row {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--border-default);
}
.scroll-restore-demo__row:last-child {
border-bottom: none;
}
// Scroll Restore demo driver.
//
// The library's value — keeping an inner scroll region's position when the DOM is
// rebuilt — is otherwise only visible by navigating away and back (or reloading).
// To make it operable in place, the "rebuild" button tears the two scroll regions
// out of the DOM and re-inserts them, which fires the controller's disconnect
// (save) → connect (restore) cycle, mirroring exactly what Turbo does on a visit.
// The left region (stimeo--scroll-restore) returns to its offset; the plain right
// region snaps back to the top.
const stage = document.querySelector("[data-scroll-restore-demo]");
if (stage) {
const slot = stage.querySelector("[data-scroll-restore-slot]");
const rebuild = stage.querySelector("[data-scroll-restore-remount]");
// Snapshot the initial markup so we can re-insert identical regions.
const template = slot.innerHTML;
rebuild.addEventListener("click", () => {
// Remove the regions → Stimulus disconnect (the controller flushes its offset).
slot.innerHTML = "";
// Re-insert on the next frame so the removal (disconnect) is processed before
// the insertion (connect); a synchronous swap could be coalesced into one
// mutation batch, leaving the save/restore ordering ambiguous.
requestAnimationFrame(() => {
slot.innerHTML = template;
});
});
}
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--scroll-restore"
Values
| Name | Description | Attribute |
|---|---|---|
key
|
The sessionStorage key for the saved offset; falls back to the element id, and persistence is disabled when neither exists. |
data-stimeo--scroll-restore-key-value |
axis
|
The axis to persist, vertical, horizontal, or both (default vertical). |
data-stimeo--scroll-restore-axis-value |