スクロール位置の保持
stimeo--scroll-restore
枠の中のスクロール位置を覚えておき、ページを移動して戻っても同じ場所を見せます。
Turbo はページを移るたびに中身を作り直すので、枠の中のスクロール位置は先頭へ戻ってしまいます。この部品はその位置を覚えておいて、戻ってきたときに同じ場所を見せます。同じタブの中であれば、ページを読み込み直しても残ります。覚える場所は名前で分けられるので、同じページに複数あってもぶつかりません。縦だけ、横だけ、両方のどれを覚えるかも選べます。読み上げにも見た目にも影響しません。
実行中
どちらのリストもスクロールしてから「作り直す」を押してください。左は元の位置に戻り、右は先頭に戻ります。Turbo でページを移ったときに起きるのと同じことです。実際にページを移動しても、同じタブなら位置は残ります。
Scroll Restore あり
- 行 1
- 行 2
- 行 3
- 行 4
- 行 5
- 行 6
- 行 7
- 行 8
- 行 9
- 行 10
- 行 11
- 行 12
- 行 13
- 行 14
- 行 15
- 行 16
- 行 17
- 行 18
- 行 19
- 行 20
なし(素のまま)
- 行 1
- 行 2
- 行 3
- 行 4
- 行 5
- 行 6
- 行 7
- 行 8
- 行 9
- 行 10
- 行 11
- 行 12
- 行 13
- 行 14
- 行 15
- 行 16
- 行 17
- 行 18
- 行 19
- 行 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" 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;
});
});
}
これらのスタイルは共通のデザイントークン(ライト/ダーク両対応)を使います。共通スタイルも一緒にコピーし、ルート要素の data-theme を切り替えればダークになります。
このコンポーネントを動かすために HTML へ記述する data-* 属性です。ルート要素に下の data-controller を付け、その内側に各 target / value / action を配置します。
ルート要素に付与
data-controller="stimeo--scroll-restore"
値(Values)
| 名前 | 説明 | 属性 |
|---|---|---|
key
|
保存位置のsessionStorageキー。未指定時は要素 id を使い、いずれも無ければ永続化を無効化する。 |
data-stimeo--scroll-restore-key-value |
axis
|
永続化する軸(vertical/horizontal/both、既定vertical)。 |
data-stimeo--scroll-restore-axis-value |