Skip to content

Instantly share code, notes, and snippets.

@iamakulov
Created July 9, 2026 14:57
Show Gist options
  • Select an option

  • Save iamakulov/5597dbaf887bceee41e850c85f885839 to your computer and use it in GitHub Desktop.

Select an option

Save iamakulov/5597dbaf887bceee41e850c85f885839 to your computer and use it in GitHub Desktop.
Manual history scroll restoration demo
<!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