On my own portfolio site, the background music player deliberately does not live on any page. It sits in the layout shell, above the router, with its state held by a single React provider, so the track keeps playing while a visitor moves from page to page. For almost every link, that arrangement did exactly what I wanted.
The ones that slipped through were the links I suspected least. The hamburger menu has a few #-anchor links, and clicking them from any page other than the home page stopped the music player, every time. Not now and then, not depending on the track.
The difference was not the destination, it was how they moved
Those links were not special in terms of where they pointed. The difference was in the handler: #-anchor navigation in the menu was done by setting window.location.href.
// Before: every anchor in the menu was treated the same way
function handleAnchor(href: string) {
window.location.href = href; // "/#work"
}Setting window.location.href to an address with a different path is not moving around inside the app. It tells the browser to throw away the live document and load a new one from scratch, which is a full page reload. And a full reload means the React tree is built again from nothing, so the layout shell remounts, the audio provider inside it remounts, and the player state dies along with that remount. The audio element that was making sound belonged to a document that has been discarded, so there is nothing left to resume.
That also explains why the symptom only showed up away from the home page. Click the same anchor while already on the home page and the only thing that changes is the hash portion of the current address, and a hash-only change does not reload the document. The moment the path changes too, the browser treats it as a new document, and the player pays for it.
The fix splits anchors into two cases
The fix separates anchor links into two cases handled differently: anchors that cross to another page, and anchors pointing at the page you are already on.
For cross-page anchors, window.location.href was replaced with router.push(). That is a soft navigation, meaning the move is performed by the router inside the app that is already running rather than by loading a new document, so the shell and the providers above the router are never remounted and the player survives.
For same-page anchors, the location is not touched at all. What gets called is scrollTo(selector) on the Lenis instance, which I keep on a global handle.
function handleAnchor(e: React.MouseEvent, href: string) {
e.preventDefault();
const [path, hash] = href.split("#");
const selector = `#${hash}`;
// Same-page anchor: do not change the location, just scroll.
if (isSamePage(path)) {
window.__scroller?.scrollTo(selector);
return;
}
// Cross-page anchor: soft navigation, the shell stays alive.
router.push(href);
}Nothing was wrong with the audio code. What was wrong was one navigation path that quietly left the application.
The second bug that shipped in the same commit
This nav fix went out in one commit together with a scroll fix, and it is no accident that the two ended up as neighbours. Lenis keeps its own scroll offset, and as a result route changes did not reset the page position back to the top.
The fix lives in the page transition component, on onExitComplete, and calls two resets at once: scrollTo(0, { immediate: true }) for the offset Lenis holds, and window.scrollTo(0, 0) for the browser's own scroll position. Then there is one exception that has to be preserved: if the address carries a hash, a cross-page anchor such as /#work for example, the code instead retries scrolling to that element once it mounts.
onExitComplete={() => {
window.__scroller?.scrollTo(0, { immediate: true });
window.scrollTo(0, 0);
const hash = window.location.hash;
if (hash) {
// Hash present: not a reset to top, chase the element after it mounts.
retryScrollTo(hash);
}
}}The retry part is needed because those two things happen at different moments. The exit transition finishes first, while the target element only exists once the next page has rendered.
What I took from it
If you deliberately put something above the router so it survives page changes, whether that is an audio player, a socket connection, or state that is expensive to rebuild, then it only survives soft navigation. window.location.href is not a shorthand for router.push(). It leaves the application and starts a fresh document, and everything you parked in the shell goes out with the old one.
What keeps this class of bug alive is that the symptom points at the wrong place. Music that stops feels like an audio problem, when the audio never knew anything about it. If some state dies through only a subset of your links, do not start with the state, start with how those links move.