Created
July 9, 2026 14:57
-
-
Save iamakulov/5597dbaf887bceee41e850c85f885839 to your computer and use it in GitHub Desktop.
Manual history scroll restoration demo
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Manual history scroll restoration demo</title> | |
| <script> | |
| // Force browsers to leave scroll position management to app code. | |
| if ("scrollRestoration" in window.history) { | |
| window.history.scrollRestoration = "manual" | |
| } | |
| </script> | |
| <style> | |
| :root { | |
| color-scheme: light dark; | |
| font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| scroll-padding-top: 32px; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| } | |
| body { | |
| margin: 0; | |
| line-height: 1.5; | |
| background: | |
| linear-gradient(90deg, color-mix(in srgb, CanvasText 7%, transparent) 1px, transparent 1px), | |
| linear-gradient(180deg, color-mix(in srgb, CanvasText 7%, transparent) 1px, transparent 1px), | |
| Canvas; | |
| background-size: 48px 48px; | |
| color: CanvasText; | |
| } | |
| button, | |
| a { | |
| font: inherit; | |
| } | |
| button { | |
| border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); | |
| border-radius: 999px; | |
| padding: 8px 12px; | |
| background: Canvas; | |
| color: CanvasText; | |
| cursor: pointer; | |
| } | |
| button:hover, | |
| a:hover { | |
| border-color: color-mix(in srgb, CanvasText 45%, transparent); | |
| } | |
| a { | |
| color: inherit; | |
| text-underline-offset: 3px; | |
| } | |
| .layout { | |
| display: grid; | |
| grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); | |
| gap: 32px; | |
| width: min(1180px, calc(100vw - 40px)); | |
| margin: 0 auto; | |
| padding: 48px 0 96px; | |
| } | |
| main { | |
| min-width: 0; | |
| } | |
| .hero, | |
| .card, | |
| .section { | |
| border: 1px solid color-mix(in srgb, CanvasText 16%, transparent); | |
| border-radius: 24px; | |
| background: color-mix(in srgb, Canvas 93%, transparent); | |
| box-shadow: 0 24px 70px rgb(0 0 0 / 12%); | |
| } | |
| .hero { | |
| padding: clamp(28px, 6vw, 72px); | |
| } | |
| h1 { | |
| max-width: 780px; | |
| margin: 0; | |
| font-size: clamp(40px, 7vw, 86px); | |
| letter-spacing: -0.06em; | |
| line-height: 0.94; | |
| } | |
| .hero p { | |
| max-width: 680px; | |
| margin: 24px 0 0; | |
| font-size: 19px; | |
| } | |
| .controls { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| margin-top: 28px; | |
| } | |
| .controls a { | |
| display: inline-flex; | |
| align-items: center; | |
| border: 1px solid color-mix(in srgb, CanvasText 20%, transparent); | |
| border-radius: 999px; | |
| padding: 8px 12px; | |
| background: Canvas; | |
| text-decoration: none; | |
| } | |
| .section { | |
| min-height: 86vh; | |
| margin-top: 32px; | |
| padding: clamp(28px, 5vw, 56px); | |
| } | |
| .section:nth-child(odd) { | |
| background: color-mix(in srgb, Canvas 88%, CanvasText 3%); | |
| } | |
| .section h2 { | |
| margin: 0 0 18px; | |
| font-size: clamp(30px, 4vw, 54px); | |
| letter-spacing: -0.04em; | |
| } | |
| .section p, | |
| .section li { | |
| max-width: 680px; | |
| font-size: 18px; | |
| } | |
| .marker { | |
| display: inline-flex; | |
| margin-bottom: 28px; | |
| border: 1px solid color-mix(in srgb, CanvasText 18%, transparent); | |
| border-radius: 999px; | |
| padding: 6px 10px; | |
| background: color-mix(in srgb, CanvasText 7%, transparent); | |
| font-size: 13px; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| .sidebar { | |
| position: sticky; | |
| top: 20px; | |
| align-self: start; | |
| } | |
| .card { | |
| padding: 18px; | |
| } | |
| .card + .card { | |
| margin-top: 16px; | |
| } | |
| .card h2 { | |
| margin: 0 0 10px; | |
| font-size: 16px; | |
| } | |
| pre { | |
| overflow: auto; | |
| max-height: 300px; | |
| margin: 0; | |
| padding: 12px; | |
| border-radius: 12px; | |
| background: color-mix(in srgb, CanvasText 8%, transparent); | |
| font-size: 12px; | |
| line-height: 1.45; | |
| white-space: pre-wrap; | |
| } | |
| .status-grid { | |
| display: grid; | |
| gap: 8px; | |
| margin: 0; | |
| } | |
| .status-grid div { | |
| display: grid; | |
| grid-template-columns: 130px 1fr; | |
| gap: 8px; | |
| } | |
| .status-grid dt { | |
| color: color-mix(in srgb, CanvasText 62%, transparent); | |
| } | |
| .status-grid dd { | |
| margin: 0; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; | |
| overflow-wrap: anywhere; | |
| } | |
| @media (max-width: 900px) { | |
| .layout { | |
| display: block; | |
| } | |
| .sidebar { | |
| position: static; | |
| margin-top: 24px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="layout"> | |
| <main> | |
| <section class="hero" id="top"> | |
| <span class="marker">Manual scroll restoration</span> | |
| <h1>Long page for history scroll restoration checks</h1> | |
| <p> | |
| This page sets <code>history.scrollRestoration = "manual"</code> before rendering the body. | |
| Use the controls to create history entries, scroll far away, then go back or forward and | |
| observe that the browser does not automatically restore the previous scroll position. | |
| </p> | |
| <div class="controls" aria-label="Demo controls"> | |
| <button type="button" id="push-entry">Push demo entry</button> | |
| <button type="button" id="replace-entry">Replace current entry</button> | |
| <button type="button" id="back">history.back()</button> | |
| <button type="button" id="forward">history.forward()</button> | |
| <a href="#middle">Jump to middle hash</a> | |
| <a href="#bottom">Jump to bottom hash</a> | |
| </div> | |
| </section> | |
| <section class="section" id="section-1"> | |
| <span class="marker">Section 01</span> | |
| <h2>Scroll a while before navigating</h2> | |
| <p> | |
| The page is intentionally tall. Push a history entry near the top, scroll down a few | |
| sections, then use back or forward. With automatic restoration disabled, the browser should | |
| not jump back to the earlier scroll offset on its own. | |
| </p> | |
| <p> | |
| The sticky panel records <code>popstate</code>, <code>hashchange</code>, and sampled scroll | |
| positions so the current behavior stays visible while moving through the document. | |
| </p> | |
| </section> | |
| <section class="section" id="section-2"> | |
| <span class="marker">Section 02</span> | |
| <h2>State entries are visible</h2> | |
| <p> | |
| Each pushed or replaced entry stores a small object with an id, timestamp, and scroll | |
| position. The live state preview updates after every interaction and while scrolling. | |
| </p> | |
| <ul> | |
| <li>Use <code>Push demo entry</code> to add a new URL search parameter.</li> | |
| <li>Use <code>Replace current entry</code> to mutate the current entry without adding one.</li> | |
| <li>Use hash links to verify anchor navigation on the same document.</li> | |
| </ul> | |
| </section> | |
| <section class="section" id="section-3"> | |
| <span class="marker">Section 03</span> | |
| <h2>Enough distance for obvious jumps</h2> | |
| <p> | |
| A short page can hide restoration bugs because the browser has little room to move. This | |
| demo gives every section nearly a viewport of height, making automatic scroll movement easy | |
| to spot. | |
| </p> | |
| </section> | |
| <section class="section" id="middle"> | |
| <span class="marker">Middle anchor</span> | |
| <h2>Middle hash target</h2> | |
| <p> | |
| This is the first explicit hash target. The URL should change to <code>#middle</code> when | |
| using the hero link, and the event log should receive a <code>hashchange</code> entry. | |
| </p> | |
| </section> | |
| <section class="section" id="section-5"> | |
| <span class="marker">Section 05</span> | |
| <h2>Manual means app-owned</h2> | |
| <p> | |
| Setting <code>scrollRestoration</code> to <code>manual</code> does not prevent scrolling. It | |
| only tells the browser not to restore saved scroll offsets during history traversal. Routers | |
| can then apply their own restoration rules. | |
| </p> | |
| </section> | |
| <section class="section" id="section-6"> | |
| <span class="marker">Section 06</span> | |
| <h2>Try a back-forward sequence</h2> | |
| <p> | |
| One useful sequence is: push an entry, scroll here, push another entry, scroll to the bottom, | |
| then press back. The URL and state should change, while the scroll position remains under | |
| manual control. | |
| </p> | |
| </section> | |
| <section class="section" id="section-7"> | |
| <span class="marker">Section 07</span> | |
| <h2>Plain platform APIs only</h2> | |
| <p> | |
| The page avoids dependencies and framework code. Everything is in this single HTML file so it | |
| can be opened locally, dropped into a bug report, or shared as a gist. | |
| </p> | |
| </section> | |
| <section class="section" id="section-8"> | |
| <span class="marker">Section 08</span> | |
| <h2>Scroll sample throttling</h2> | |
| <p> | |
| The log samples scroll events with <code>requestAnimationFrame</code>. That keeps the panel | |
| responsive without appending a new line for every raw scroll event. | |
| </p> | |
| </section> | |
| <section class="section" id="section-9"> | |
| <span class="marker">Section 09</span> | |
| <h2>Hash links still scroll</h2> | |
| <p> | |
| Manual restoration does not disable normal anchor behavior. Clicking a hash link still moves | |
| the viewport to the target element. | |
| </p> | |
| </section> | |
| <section class="section" id="bottom"> | |
| <span class="marker">Bottom anchor</span> | |
| <h2>Bottom hash target</h2> | |
| <p> | |
| This is the bottom target. Use the controls in the sticky panel or browser navigation buttons | |
| to move through the history entries created above. | |
| </p> | |
| <p><a href="#top">Back to top hash</a></p> | |
| </section> | |
| </main> | |
| <aside class="sidebar" aria-label="Live history details"> | |
| <section class="card"> | |
| <h2>Live status</h2> | |
| <dl class="status-grid"> | |
| <div> | |
| <dt>scrollRestoration</dt> | |
| <dd id="scroll-restoration"></dd> | |
| </div> | |
| <div> | |
| <dt>scrollY</dt> | |
| <dd id="scroll-y"></dd> | |
| </div> | |
| <div> | |
| <dt>URL</dt> | |
| <dd id="url"></dd> | |
| </div> | |
| </dl> | |
| </section> | |
| <section class="card"> | |
| <h2>history.state</h2> | |
| <pre id="state-preview"></pre> | |
| </section> | |
| <section class="card"> | |
| <h2>Event log</h2> | |
| <pre id="event-log"></pre> | |
| </section> | |
| </aside> | |
| </div> | |
| <script> | |
| const scrollRestoration = document.querySelector("#scroll-restoration") | |
| const scrollY = document.querySelector("#scroll-y") | |
| const url = document.querySelector("#url") | |
| const statePreview = document.querySelector("#state-preview") | |
| const eventLog = document.querySelector("#event-log") | |
| const logEntries = [] | |
| let nextEntryIndex = 1 | |
| let scrollUpdateQueued = false | |
| const getBaseUrl = () => `${window.location.pathname}${window.location.search}` | |
| const getState = action => ({ | |
| action, | |
| createdAt: new Date().toISOString(), | |
| entryIndex: nextEntryIndex, | |
| scrollY: Math.round(window.scrollY), | |
| }) | |
| const render = () => { | |
| scrollRestoration.textContent = "scrollRestoration" in window.history | |
| ? window.history.scrollRestoration | |
| : "unsupported" | |
| scrollY.textContent = String(Math.round(window.scrollY)) | |
| url.textContent = window.location.href | |
| statePreview.textContent = JSON.stringify(window.history.state, null, 2) | |
| } | |
| const record = eventName => { | |
| logEntries.unshift({ | |
| eventName, | |
| time: new Date().toLocaleTimeString(), | |
| hash: window.location.hash, | |
| scrollY: Math.round(window.scrollY), | |
| historyState: window.history.state, | |
| }) | |
| logEntries.splice(10) | |
| eventLog.textContent = JSON.stringify(logEntries, null, 2) | |
| render() | |
| } | |
| const writeState = (method, action) => { | |
| const state = getState(action) | |
| const url = `${getBaseUrl().split("?")[0]}?entry=${nextEntryIndex}${window.location.hash}` | |
| nextEntryIndex += 1 | |
| window.history[method](state, "", url) | |
| record(method) | |
| } | |
| document.querySelector("#push-entry").addEventListener("click", () => writeState("pushState", "push")) | |
| document.querySelector("#replace-entry").addEventListener("click", () => writeState("replaceState", "replace")) | |
| document.querySelector("#back").addEventListener("click", () => window.history.back()) | |
| document.querySelector("#forward").addEventListener("click", () => window.history.forward()) | |
| window.addEventListener("popstate", () => record("popstate")) | |
| window.addEventListener("hashchange", () => record("hashchange")) | |
| window.addEventListener("scroll", () => { | |
| if (scrollUpdateQueued) return | |
| scrollUpdateQueued = true | |
| window.requestAnimationFrame(() => { | |
| scrollUpdateQueued = false | |
| record("scroll") | |
| }) | |
| }, { passive: true }) | |
| window.history.replaceState(getState("boot"), "", window.location.href) | |
| nextEntryIndex += 1 | |
| record("boot") | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment