{"id":"jmesyk3yd28s6lf","title":"Hash Deep Links in React SPAs Without Fighting ScrollToTop","slug":"spa-hash-deep-links-without-scroll-fights","summary":"Homepage cards deep-link to lazy below-fold targets on /projects/ and /journal/; hash-aware ScrollToTop, an idle-gated scroll hook with retries, and scroll-margin-top keep the anchor from fighting your SPA.","imageUrl":"https://briancrabtree.me/images/journal-spa-hash-deep-links-without-scroll-fights.webp","category":"Engineering","date":"2026-06-07T18:00:00.000Z","featured":false,"likes":12,"author":"Brian Crabtree","content":"<h2>What breaks when you add hash links to a React SPA</h2>\n\n<p>Homepage cards should land on the right project or Field Note—not the top of a long index page. That means linking to <code>/projects/#project-highline-real-estate</code> or <code>/journal/#journal-mock-first-handoff-before-mls-integration</code> instead of bare route paths. In a client-rendered app, three things fight that intent on every navigation.</p>\n\n<p>First, a global <code>ScrollToTop</code> helper runs on pathname change and snaps the viewport to zero. Second, the hash target often lives in a lazy below-fold section that has not mounted yet—so <code>document.getElementById</code> returns null on the first paint. Third, a sticky header covers the top of the scrolled element unless you reserve offset with CSS. Miss any one of those and the user clicks a card and lands nowhere useful.</p>\n\n<p>I wired this on <a href=\"https://briancrabtree.me/\">briancrabtree.me</a> so homepage project and journal tiles scroll to the matching row on <a href=\"https://briancrabtree.me/projects/\">/projects/</a> and <a href=\"https://briancrabtree.me/journal/\">/journal/</a>. The pattern is small, but every piece has to agree.</p>\n\n<h2>Step one: ScrollToTop must respect the hash</h2>\n\n<p>The default SPA scroll reset is correct for normal route changes. It is wrong when the URL carries an anchor. If pathname changes and you always call <code>window.scrollTo(0, 0)</code>, you erase the browser’s hash intent before your scroll hook runs.</p>\n\n<p>The fix is one guard: if <code>location.hash</code> is non-empty, return early and let the hash handler own vertical position. Route changes without a hash still scroll to top. Deep links with <code>#project-…</code> or <code>#journal-…</code> skip the reset.</p>\n\n<h2>Step two: one helper for hash URLs and element ids</h2>\n\n<p>Hash fragments and DOM ids must match exactly. I centralize both in <code>utils/journalUrl.ts</code>: <code>projectIndexHashPath(id)</code> returns <code>/projects/#project-{id}</code>, and <code>journalIndexHashPath(slug)</code> returns <code>/journal/#journal-{slug}</code>. The portfolio and journal index pages render matching <code>id</code> attributes on each card row.</p>\n\n<p>Homepage grids import those helpers—no string concatenation scattered across components. When the id scheme changes, one file updates and every link stays consistent.</p>\n\n<pre><code>- [ ] ScrollToTop skips when location.hash is set\n- [ ] hash path helpers match card id attributes\n- [ ] below-fold section gated until idle (requestIdleCallback)\n- [ ] scroll hook retries until getElementById succeeds\n- [ ] scroll-margin-top clears sticky header\n- [ ] journal hash selects correct archive page before scroll</code></pre>\n\n<h2>Step three: wait for lazy below-fold before scrolling</h2>\n\n<p>Portfolio and Field Notes index pages defer heavy below-fold markup until the shell paints. Featured hero, skeleton states, and API fetches finish first; the project grid and archive list mount after <code>requestIdleCallback</code> (with a timeout fallback). That keeps first paint fast—but it means the hash target does not exist on initial render.</p>\n\n<p>A scroll hook gated on <code>ready && !loading && data.length</code> avoids firing into an empty DOM. When below-fold flips ready, the hook reads <code>location.hash</code>, strips the prefix (<code>project-</code> or <code>journal-</code>), finds the element, and calls <code>scrollIntoView({ behavior: 'smooth', block: 'start' })</code>.</p>\n\n<p>Elements still miss on the first tick if React is mid-commit. I retry on a short backoff schedule—0 ms, 50 ms, 150 ms, up to 3 seconds—until the target mounts or the user navigates away. No infinite loops, no <code>MutationObserver</code> tax.</p>\n\n<h2>Step four: journal pagination and search</h2>\n\n<p>Field Notes archive paginates. A hash like <code>#journal-some-slug</code> might point at a post on page three. Before scrolling, parse the slug from the hash, find its index in the filtered list, compute the page number, and set current page if needed. Clear an active search filter so the target row is actually in the DOM.</p>\n\n<p>Page changes normally scroll to top. When a journal hash is present, skip that reset—the hash hook will position the viewport once the correct page renders.</p>\n\n<h2>Step five: scroll-margin for sticky headers</h2>\n\n<p><code>scrollIntoView</code> aligns the element’s top edge with the viewport top. A fixed nav sits on that edge and hides the card title. <code>scroll-margin-top: 120px</code> on portfolio rows and journal cards reserves space so the scrolled target clears the header buffer. Pure CSS, no JavaScript offset math.</p>\n\n<h2>Verify on live URLs</h2>\n\n<p>From the homepage, click a project card—you should land on the matching row on the portfolio page, not the featured block at the top. Try a direct load:</p>\n\n<p><a href=\"https://briancrabtree.me/projects/#project-highline-real-estate\">briancrabtree.me/projects/#project-highline-real-estate</a></p>\n\n<p><a href=\"https://briancrabtree.me/journal/#journal-mock-first-handoff-before-mls-integration\">briancrabtree.me/journal/#journal-mock-first-handoff-before-mls-integration</a></p>\n\n<p>Both should smooth-scroll to the anchored card with the title visible below the nav. If you still land at the top, walk the checklist: hash guard on ScrollToTop, ready gate, retry hook, scroll-margin, and—for journal—pagination sync.</p>\n\n<h2>Why this stays performant</h2>\n\n<p>None of this blocks LCP. Below-fold content stays lazy. The scroll hook is a few timeouts and one DOM lookup—negligible next to fetch and render. You get deep links that behave like a multi-page site without surrendering the SPA shell or inflating the critical path.</p>\n\n<p>Hash deep links are not exotic. They are baseline UX for any index page with homepage teasers. The implementation is boring on purpose: guard the global scroll reset, centralize URL shape, wait for the target, retry, offset for sticky chrome. Ship that once and every card link on the site inherits correct behavior.</p>","tags":["react","react-router","spa","accessibility","scroll-behavior"],"views":19}