Step‑by‑Step Guide to Managing Dynamic In‑Page Links for Single‑Page Apps
When a user lands on a long page and clicks a menu item, they expect the view to jump right where they need it. In a single‑page app (SPA) that expectation can break if you’re not careful with in‑page anchors. That’s why getting dynamic links right matters now more than ever—users are impatient, and search engines love clean navigation.
Why SPAs Need a Different Anchor Strategy
A traditional multi‑page site reloads the whole document when you follow a link. The browser automatically scrolls to the element whose id matches the hash (#section). In an SPA, the page never reloads. Content is often added, removed, or reordered after the initial load, so the hash may point to an element that doesn’t exist yet. If you don’t handle that, the page just sits there, confused.
The Core Pieces
- Hash change listener – watches the URL fragment (
window.location.hash). - Element registration – a way for components to tell the app “I’m here, my id is X”.
- Smooth scrolling logic – optional but makes the experience feel polished.
- Accessibility check – focus must move to the target element so screen readers announce it.
Step 1: Set Up a Central Anchor Manager
Create a tiny module that stores a map of ids to DOM nodes. Here’s a plain‑JS example:
// anchorManager.js
const anchors = new Map()
export function registerAnchor(id, element) {
if (id && element) {
anchors.set(id, element)
}
}
export function unregisterAnchor(id) {
anchors.delete(id)
}
export function getAnchor(id) {
return anchors.get(id)
}
Every component that renders a section can call registerAnchor in a useEffect (React) or connectedCallback (Web Components). When the component unmounts, call unregisterAnchor to keep the map clean.
Step 2: Listen for Hash Changes
Add a listener once, preferably at the root of your app:
// scrollHandler.js
import { getAnchor } from './anchorManager.js'
function scrollToHash() {
const hash = window.location.hash.slice(1) // remove the #
if (!hash) return
const target = getAnchor(hash)
if (target) {
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Move keyboard focus for accessibility
target.setAttribute('tabindex', '-1')
target.focus()
}
}
// Run on load in case the URL already has a hash
window.addEventListener('DOMContentLoaded', scrollToHash)
window.addEventListener('hashchange', scrollToHash)
The scrollIntoView call works in all modern browsers and gives you a smooth scroll without extra libraries.
Step 3: Register Anchors in Your Components
If you’re using React, a tiny hook does the job:
// useAnchor.js
import { useEffect, useRef } from 'react'
import { registerAnchor, unregisterAnchor } from './anchorManager.js'
export function useAnchor(id) {
const ref = useRef(null)
useEffect(() => {
if (ref.current) {
registerAnchor(id, ref.current)
}
return () => {
unregisterAnchor(id)
}
}, [id])
return ref
}
Then in a component:
function Section({ id, title, children }) {
const ref = useAnchor(id)
return (
<section id={id} ref={ref}>
<h2>{title}</h2>
{children}
</section>
)
}
The same idea works in Vue, Svelte, or plain JavaScript—just make sure the element is registered after it appears in the DOM.
Step 4: Handle Dynamic Content Loading
Sometimes a section is loaded only after a network request. In that case, the hash may already be in the URL when the data arrives. After you render the new content, call scrollToHash manually:
import { scrollToHash } from './scrollHandler.js'
async function loadSectionData(id) {
const data = await fetch(`/api/section/${id}`).then(r => r.json())
// render data...
// once the DOM node exists:
scrollToHash()
}
Because the manager already knows about the new element, the scroll will happen instantly.
Step 5: Keep SEO and Accessibility Happy
Even though SPAs rely on JavaScript, search engines still read the static HTML that’s sent initially. Make sure each anchorable section has a real id attribute in the markup. That way a crawler can see the structure without running JS.
For accessibility, always move focus to the target element after scrolling. Adding tabindex="-1" lets you focus an element that isn’t normally focusable (like a <div>). Screen readers will then announce the heading or content inside.
Common Pitfalls and How to Avoid Them
| Problem | Why it Happens | Fix |
|---|---|---|
| Scroll jumps to top | hashchange fires before the element is in the DOM | Delay the scroll until after registration, or use a setTimeout of 0 as a quick hack |
| Multiple elements share the same id | Duplicate ids break the map lookup | Enforce unique ids, perhaps by prefixing with the page name |
| Focus ring disappears | Browser removes focus after smooth scroll | Keep tabindex="-1" on the target, or add a CSS rule to show focus on programmatic focus |
Quick Checklist for Every New SPA Page
- [ ] Every section that should be reachable has a unique
id. - [ ] The component registers itself with the anchor manager.
- [ ] A global hash listener calls
scrollIntoViewwith smooth behavior. - [ ] After dynamic loads, invoke the scroll function again.
- [ ] Focus is moved to the target element for screen readers.
Follow this checklist and you’ll have in‑page navigation that feels as fast as a native app, works for assistive tech, and stays SEO‑friendly.
- → Boost Your First Paint: Practical CSS Techniques to Cut Load Time by 40% @codecanvas
- → How to Add a Custom ChatGPT Widget to Your React Site @techtrails
- → Responsive Design Secrets: Crafting Fluid Layouts that Adapt to Any Device @codecraftchronicles
- → Optimizing Web Performance: How to Reduce Load Times with Lazy Loading and Code Splitting @codecraftchronicles
- → Designing Accessible UI: Practical Tips for Inclusive Web Experiences @codecraftchronicles