Scroll Restore
stimeo--scroll-restore
Remembers where you were inside a scrolling box, so leaving and coming back shows the same place.
Turbo rebuilds the page on every visit, so a scrolling box inside it comes back at the top. This remembers where you were and puts you back there. Within the same tab it survives a full reload too. What it remembers is filed under a name, so several boxes on a page never collide, and you choose whether to remember the vertical position, the horizontal one, or both. It changes nothing for screen readers and nothing visually.
Scroll both lists, then press rebuild. The left one returns to where you were and the right one jumps to the top, which is what happens to a scrolling box on a Turbo visit. A real navigation keeps the position too, within the same tab.
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
Keyboard
This component has no keyboard interactions of its own.
<%# 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" id="scroll-restore-with-label"><%= t(
"components.scroll_restore.demo.with_label"
) %></p>
<%# Scrolls, but every child is plain <li> text: without a tab stop the list is
unreachable by keyboard (WCAG 2.1.1). Named by the label above rather than a new
string, so the two boxes stay distinguishable to a screen reader. %>
<div
class="scroll-restore-demo__box"
tabindex="0"
role="region"
aria-labelledby="scroll-restore-with-label"
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" id="scroll-restore-without-label"><%= t(
"components.scroll_restore.demo.without_label"
) %></p>
<div
class="scroll-restore-demo__box"
tabindex="0"
role="region"
aria-labelledby="scroll-restore-without-label">
<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);
/* Without this the button inherits the UA default black, which lands at 1.24:1
on the dark card. */
color: var(--fg);
}
.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 |