How a page transition works without a framework
A page transition feels like a small trick: the old page slides out, the new one fades in, and a persistent background never reloads. Underneath it is ordinary web plumbing: intercept a click, fetch the next page, swap a region of the document, update the address bar. The trick is easy; the work is in everything a normal navigation did for free. This walks through both, with code that was checked for syntax as one module.
What a normal navigation gives you
When a visitor clicks a link the browser does a lot without being asked: it changes the URL, adds a history entry, fetches and parses the page, resets scroll, restores scroll on Back, updates the title, moves focus, tells assistive technology that the page changed, shows errors, and handles modified clicks (new tab, new window, download). A soft transition takes responsibility for every one of those items. Before writing code, accept that list as the specification.
The rule that keeps it safe is enhancement, not replacement. Every page must be a complete HTML document at its own URL (see static HTML first). The script is an optional layer, and any failure falls back to a plain navigation.
Step 1: intercept the right clicks
Only handle clicks the browser would handle as a normal same-tab navigation:
document.addEventListener('click', (event) => {
const link = event.target.closest('a[href]');
if (!link) return;
if (event.defaultPrevented || event.button !== 0) return;
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
if (link.target && link.target !== '_self') return;
if (link.hasAttribute('download')) return;
if (link.origin !== location.origin) return;
const samePageHash = link.pathname === location.pathname
&& link.search === location.search && link.hash;
if (samePageHash) return; // let the browser jump to the fragment
event.preventDefault();
navigate(link.href, { push: true });
});
Every early return is a normal-browser behaviour you are choosing not to break. Missing the modifier-key check is the classic bug: Cmd-click stops opening a new tab.
Step 2: fetch and parse
const cache = new Map(); // url -> { html, url }
let controller = null;
async function load(url) {
if (cache.has(url)) return cache.get(url);
const response = await fetch(url, {
signal: controller?.signal,
headers: { Accept: 'text/html' },
});
const type = response.headers.get('content-type') || '';
if (!response.ok || !type.includes('text/html')) throw new Error('not a page');
const page = { html: await response.text(), url: response.url };
cache.set(url, page);
return page;
}
function parse(html) {
return new DOMParser().parseFromString(html, 'text/html');
}
Cache the string, not the parsed document, because adopting nodes out of a document removes them from it. The status and content-type checks send errors and non-HTML resources to the real browser instead of rendering garbage. MDN documents response.url as the final URL after any redirects, so that is the address to put in the history, which is why the cache stores it next to the HTML. A server that redirects every /about to /about/ is the common case; linking to the final form directly saves a hop.
Add prefetching to cut perceived latency: on pointerenter and focusin of an internal link, call load(link.href).catch(() => {}). Keep the cache small and let entries expire; stale cached pages are a bug when content changes. A hover is not a click, so prefetching spends the visitor's bandwidth on pages they may never open.
Step 3: swap, but push first
let scrollTimer;
let shown = location.pathname + location.search; // the page whose content is on screen
async function navigate(url, { push }) {
controller?.abort();
controller = new AbortController();
let pushed = false;
try {
const page = await load(url);
const finalUrl = new URL(page.url);
finalUrl.hash = new URL(url, location.href).hash;
if (finalUrl.origin !== location.origin) throw new Error('left the origin');
const doc = parse(page.html);
if (push) {
clearTimeout(scrollTimer);
history.replaceState({ ...history.state, y: scrollY }, ''); // save where we left
history.pushState({}, '', finalUrl); // BEFORE inserting new content
pushed = true;
}
await swapWithTransition(doc);
afterSwap({ push, url: finalUrl.href });
} catch (error) {
if (error.name === 'AbortError') return;
// a real navigation as the fallback; replace if an entry for this URL already exists
if (pushed || !push) location.replace(location.href);
else location.assign(url);
}
}
function swap(doc) {
const current = document.querySelector('main');
const next = doc.querySelector('main');
if (!next) throw new Error('no main');
document.title = doc.title;
syncHead(doc); // description, canonical, og tags
current.replaceWith(document.adoptNode(next));
}
Four details:
- Push before inserting. MDN's
baseURIpage says the base URL defaults to the document's location, so relative URLs in adopted content (src="img/a.jpg") resolve against whatever the address bar says when they are processed. Swap first and they resolve against the previous page. - Abort the previous request. Fast double clicks otherwise produce a race where the older response arrives last and wins.
- Pick the right fallback. If the failure happens before
pushState,location.assign(url)gives the browser a normal navigation with a new history entry. AfterpushState, or on a Back or Forward traversal, the entry for that URL already exists, solocation.replacereloads it without adding a duplicate that would trap the Back button. - Same origin only.
pushStatethrows aSecurityErrorfor a URL on another origin, per MDN, so a redirect that leaves the site is sent to the fallback.
syncHead should update the things that identify the page: title, meta description, canonical link, Open Graph tags. Leave persistent elements such as fonts, stylesheets and a persistent canvas alone by touching only a known list of tags.
Scripts in the new content do not run. The HTML Standard states that scripts in DOMParser-created documents will not execute because scripting is disabled for them. Its algorithm for preparing a script element also sets the element's "already started" flag before it checks whether scripting is disabled, so the element stays inert after you move it into the live page. Do not rely on inline scripts in page content; dispatch an event (document.dispatchEvent(new CustomEvent('page:enter'))) and initialise behaviour from the one script that stays loaded.
Step 4: scroll, focus and announcement
A swap that leaves the viewport halfway down the old page's scroll position, and keyboard focus on a link that no longer exists, is broken.
history.scrollRestoration = 'manual';
addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
history.replaceState({ ...history.state, y: scrollY }, '');
}, 100);
}, { passive: true });
function afterSwap({ push, url }) {
const hash = new URL(url, location.href).hash;
const target = hash && document.getElementById(decodeURIComponent(hash.slice(1)));
if (target) {
target.scrollIntoView();
} else if (push) {
scrollTo(0, 0);
} else {
scrollTo(0, history.state?.y ?? 0); // Back and Forward restore
}
const heading = document.querySelector('main h1');
if (heading) {
heading.setAttribute('tabindex', '-1');
heading.focus({ preventScroll: true });
}
const shownUrl = new URL(url, location.href);
shown = shownUrl.pathname + shownUrl.search;
updateCurrentLink(); // aria-current in the menu
document.dispatchEvent(new CustomEvent('page:enter'));
}
- Scroll restoration. MDN defines
scrollRestorationvaluesauto(the browser restores the position) andmanual(it does not, and you must scroll yourself). Manual stops the browser from restoring a position that no longer matches the swapped content. Store the position in the history entry's state as the visitor scrolls (a debouncedreplaceStatekeeps it current), and read it back onpopstate. - Focus. Moving focus to the new page's
<h1>resets the keyboard position and prompts screen readers to read the new heading. MDN says atabindexof-1makes an element focusable by script without adding it to the sequential tab order.preventScroll: truestops the focus call from scrolling the element into view, which would fight your own scroll handling. - Announcement. Focus on the heading is one mechanism. The alternative is a polite live region that receives the new title. MDN's
aria-liveexample is an empty element witharia-live="polite"that is later updated with a brief message, so put an empty region in the markup and write the new title into it. Pick one primary mechanism, because doing both can read the title twice. - What WCAG 4.1.3 says. The Understanding page limits the criterion to status messages: information about results, progress or errors that does not take focus and does not arrive by a change of context. It notes that changes of context take focus and are already surfaced by assistive technology. A full navigation is therefore a focus-management decision more than a 4.1.3 obligation. The criterion does apply to your own loading and error messages, such as "Page failed to load, opening it normally".
Step 5: Back and Forward
addEventListener('popstate', () => {
const here = location.pathname + location.search;
if (here === shown) { // only the #fragment changed
const id = decodeURIComponent(location.hash.slice(1));
const target = id && document.getElementById(id);
if (target) target.scrollIntoView(); else scrollTo(0, history.state?.y ?? 0);
return;
}
navigate(location.href, { push: false });
});
MDN's popstate page notes that location already reflects the new entry when the event fires, so you only need to load and swap. The saved y in history.state restores scroll. The guard matters because Step 1 lets same-page #fragment links navigate natively, and each one adds a history entry. Pressing Back over such an entry fires popstate too; without the check, the handler would refetch the page you are already on, throw away any text typed into a form, and move focus to the heading, for what should be a scroll. Test the sequence: land on A, go to B, scroll B, go to C, press Back twice, press Forward once. Each step should land on the right page at the right position.
Step 6: add the animation
With the swap in place, the animation is a wrapper. A hand-rolled version adds a class to hide the old content, waits for transitionend, swaps, then removes the class. Keep durations short and skip the animation entirely under reduced motion, as described in prefers-reduced-motion, done properly; easing is a personality covers choosing curves for it.
The View Transitions API gives you a browser-managed version of the same idea:
function swapWithTransition(doc) {
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.startViewTransition || reduce) return swap(doc);
return document.startViewTransition(() => swap(doc)).updateCallbackDone;
}
The browser snapshots the old state, runs your update, and animates to the new state, which you can restyle with CSS pseudo-elements. Waiting on updateCallbackDone (documented as fulfilling when your callback's promise does) lets focus and scroll be set as soon as the DOM is updated, without waiting for the animation. MDN's overview adds a reason to keep it: hand-built transitions that hold old and new content in the DOM at once can cause loss of reading position, focus confusion and odd live-region announcements.
Support is a moving target, so date it. MDN, read on 2026-10-17, marks document.startViewTransition() as Baseline 2025, newly available since October 2025. Keep the plain-swap branch anyway for older browsers. The cross-document variant, enabled with @view-transition { navigation: auto; } in CSS, animates ordinary page loads without any fetch-and-swap script. MDN marks that at-rule as limited availability, not Baseline, and says it needs the two documents to be same-origin, with no cross-origin redirects, and, for a new navigation, one the user started from the page content rather than from browser UI. If your pages can stay separate documents and you want only an animation, that may be all you need. Fetch-and-swap remains the right tool when something must persist across routes, such as a canvas or an audio player.
The Navigation API is a third route. MDN marks it Baseline 2026, newly available since January 2026. Its navigate event lets you call event.intercept() with a handler, and options focusReset and scroll, each defaulting to after-transition and each switchable to manual. The browser's default focus reset moves focus to the first element with autofocus or to <body>, not to your heading, so set focusReset: 'manual' and focus the heading yourself if you want the behaviour in Step 4.
Failure modes to test
| Situation | Expected behaviour |
|---|---|
| Server returns 404 or 500 | Full navigation, so the real error page appears |
| Offline | Full navigation, so the browser's offline page appears |
| Link to a PDF or download | Not intercepted |
| Cmd/Ctrl-click, middle click | Opens a new tab, untouched |
| Fast double click on two links | Only the second navigation completes |
| Fragment link on the same page | Native jump |
| Back after a same-page fragment jump | Scrolls to the previous position; no refetch, form state and focus kept |
| JavaScript disabled | Everything works as normal links |
Page without <main> | Fallback to full navigation, one history entry |
| Redirect to another origin | Fallback; no SecurityError left unhandled |
A quick way to prove the history behaviour is to open the console after a failed soft navigation and read history.length before and after. If the fallback added an entry, the number is one higher than a plain click would give, and the Back button will appear to do nothing the first time.
Checklist
- Every page is a complete document at its own URL; the script only enhances.
- Intercept only plain same-origin, same-tab, unmodified left clicks.
- Push history (using the final URL) before inserting content; abort stale requests; fall back to a real navigation on any error, without a duplicate entry.
- Set manual scroll restoration, save position in history state, restore on
popstate. - Move focus to the new
<h1>(or update an existing live region), updatearia-current, sync title and canonical, and re-initialise behaviour from your own script after each swap.
Questions and answers
Does a soft page transition hurt SEO?
Not by itself, if every page is a real URL that returns complete HTML when requested directly. The transition layer only intercepts clicks for visitors running your script; anything that requests the URL on its own gets an ordinary page. Whether any particular crawler runs your script is not something to rely on.
Why do scripts inside the fetched page not run?
The HTML Standard says scripting is disabled in documents created by DOMParser, and the preparation step marks a script as already started before it checks that, so moving the element into the live page does not make it run. Initialise behaviour from your own code after each swap.
Should I use the View Transitions API instead?
It suits the animation step, with a fallback for browsers without it. It does not replace fetching, history, focus, scroll restoration or error handling for same-document navigation. The cross-document variant removes the need for a swap script, but MDN marks it as limited availability.
How do screen reader users know the page changed?
Nothing announces a swapped page automatically, so move keyboard focus to the new main heading or update a polite live region that already exists in the page. Test with a real screen reader rather than assuming either approach works.
Sources
- MDN: History.pushState() developer.mozilla.org
- MDN: History.scrollRestoration developer.mozilla.org
- MDN: View Transition API developer.mozilla.org
- HTML Standard: Scripting (preparing a script element) html.spec.whatwg.org
- WCAG 2.2: Understanding Status Messages (4.1.3) w3.org