javascriptroom blog

How to Auto-Update URL Anchor in Vue Router on Scroll: Sync Route Hash with Scroll Position

URL anchors (or hashes) are a fundamental web feature that allows users to navigate directly to specific sections of a page (e.g., https://example.com/docs#installation). By default, Vue Router handles hash-based navigation when users click links, but it doesn’t automatically update the URL hash as users scroll through content. This disconnect can frustrate users who want to bookmark or share their current view, or rely on hashes for navigation cues.

In this guide, we’ll walk through syncing the Vue Router hash with the user’s scroll position in a Vue.js application. You’ll learn how to detect scroll events, identify the active section in view, and update the route hash dynamically—creating a seamless, user-friendly experience.

2026-02

Table of Contents#

Prerequisites#

Before diving in, ensure you have:

  • Basic knowledge of Vue.js (Vue 3 recommended)
  • Familiarity with Vue Router (v4+ for Vue 3)
  • A Vue project set up (use vue create or Vite for quick setup)
  • Optional: lodash.throttle (for scroll event throttling, or a custom implementation)

Understanding the Problem#

By default, Vue Router updates the URL hash only when the user clicks a link (e.g., <router-link to="#section-1">). If the user scrolls manually to "Section 2," the URL remains unchanged (e.g., https://example.com/page instead of https://example.com/page#section-2).

Our goal is to:

  1. Detect when the user scrolls.
  2. Determine which section is currently in view.
  3. Update the Vue Router hash to match that section’s ID.

Step-by-Step Implementation#

1. Project Setup & Vue Router Configuration#

First, ensure Vue Router is installed. If not, add it to your project:

npm install vue-router@4  # For Vue 3  

Configure Vue Router in src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router';  
import ScrollPage from '../views/ScrollPage.vue'; // Our target page  
 
const routes = [  
  {  
    path: '/',  
    name: 'ScrollPage',  
    component: ScrollPage  
  }  
];  
 
const router = createRouter({  
  history: createWebHistory(),  
  routes,  
  // Optional: Enable smooth scrolling for hash navigation  
  scrollBehavior(to, from, savedPosition) {  
    if (to.hash) {  
      return {  
        el: to.hash,  
        behavior: 'smooth' // Adds smooth scroll animation  
      };  
    }  
    return savedPosition || { top: 0 };  
  }  
});  
 
export default router;  

2. Markup Structure: Sections with IDs#

Create a page (ScrollPage.vue) with scrollable sections. Each section must have a unique id (the target of the URL hash). Add a navigation menu for context:

<template>  
  <div class="scroll-page">  
    <!-- Navigation Links -->  
    <nav class="section-nav">  
      <router-link :to="{ hash: '#section-1' }">Section 1</router-link>  
      <router-link :to="{ hash: '#section-2' }">Section 2</router-link>  
      <router-link :to="{ hash: '#section-3' }">Section 3</router-link>  
    </nav>  
 
    <!-- Scrollable Sections -->  
    <main class="sections-container">  
      <section id="section-1" class="section">  
        <h2>Section 1</h2>  
        <p>Content for section 1...</p>  
      </section>  
 
      <section id="section-2" class="section">  
        <h2>Section 2</h2>  
        <p>Content for section 2...</p>  
      </section>  
 
      <section id="section-3" class="section">  
        <h2>Section 3</h2>  
        <p>Content for section 3...</p>  
      </section>  
    </main>  
  </div>  
</template>  
 
<style scoped>  
/* Add styling for sections (tall enough to scroll) */  
.section {  
  min-height: 80vh; /* Each section takes ~80% of viewport height */  
  padding: 2rem;  
  border-bottom: 1px solid #eee;  
}  
.section-nav {  
  position: sticky;  
  top: 0;  
  background: white;  
  padding: 1rem;  
  display: flex;  
  gap: 1rem;  
}  
</style>  

3. Detect Scroll Position (with Throttling)#

Scroll events fire frequently (dozens of times per second), which can harm performance. To mitigate this, throttle the scroll handler to run at most once every 100ms (adjustable).

Option 1: Use Lodash Throttle (Simpler)#

Install Lodash:

npm install lodash.throttle  

In ScrollPage.vue, import and use throttling:

import { throttle } from 'lodash.throttle';  
 
export default {  
  name: 'ScrollPage',  
  mounted() {  
    // Throttle scroll events to 100ms intervals  
    this.handleScroll = throttle(this.updateHashOnScroll, 100);  
    window.addEventListener('scroll', this.handleScroll);  
  },  
  beforeUnmount() {  
    // Clean up: remove scroll listener to prevent memory leaks  
    window.removeEventListener('scroll', this.handleScroll);  
    this.handleScroll.cancel(); // Cancel throttling  
  },  
  methods: {  
    updateHashOnScroll() {  
      // Logic to update hash (implemented next)  
    }  
  }  
};  

Option 2: Custom Throttle Function (No Dependencies)#

If you prefer not to use Lodash, implement a simple throttle function:

methods: {  
  throttle(func, limit) {  
    let lastCall = 0;  
    return function (...args) {  
      const now = Date.now();  
      if (now - lastCall < limit) return;  
      lastCall = now;  
      return func.apply(this, args);  
    };  
  }  
},  
mounted() {  
  this.handleScroll = this.throttle(this.updateHashOnScroll, 100);  
  window.addEventListener('scroll', this.handleScroll);  
}  

4. Identify Active Section on Scroll#

To update the hash, we first need to find which section is currently in view. Use getBoundingClientRect() to check if a section’s top edge is within the viewport.

Add this logic to updateHashOnScroll:

updateHashOnScroll() {  
  const sections = document.querySelectorAll('section[id]'); // Get all sections with IDs  
  let activeSectionId = '';  
 
  // Define a threshold (e.g., 100px from top of viewport)  
  const threshold = 100;  
 
  sections.forEach(section => {  
    const sectionTop = section.getBoundingClientRect().top;  
 
    // Check if section is within the threshold (adjust as needed)  
    if (sectionTop <= threshold && sectionTop >= -section.offsetHeight + threshold) {  
      activeSectionId = section.getAttribute('id');  
    }  
  });  
 
  // Update hash if active section exists and differs from current hash  
  if (activeSectionId && this.$route.hash !== `#${activeSectionId}`) {  
    this.$router.replace({ hash: `#${activeSectionId}` });  
  }  
}  

How It Works:

  • getBoundingClientRect().top returns the section’s distance from the top of the viewport.
  • The threshold ensures the section is "close enough" to the top (e.g., 100px) before updating the hash.
  • section.offsetHeight accounts for cases where a section is taller than the viewport.

5. Update Route Hash with Vue Router#

Once the active section is identified, use this.$router.replace({ hash: ... }) to update the URL hash. We use replace instead of push to avoid cluttering the browser’s history with every scroll event (users can still use the back button to navigate between sections).

6. Handle Initial Load & Navigation#

  • Initial Load: If the page loads with a hash (e.g., https://example.com/#section-2), Vue Router’s scrollBehavior (configured earlier) will scroll to that section automatically.
  • Manual Navigation: If the user clicks a nav link (e.g., <router-link to="#section-3">), Vue Router updates the hash and scrolls to the section—no extra work needed!

Advanced Considerations#

Throttling vs. Debouncing#

  • Throttling: Limits how often a function runs (e.g., once every 100ms). Ideal for scroll events, as we need regular updates.
  • Debouncing: Delays execution until after a pause (e.g., 300ms after scrolling stops). Less useful here, as we want the hash to update during scrolling.

Adjusting Scroll Thresholds#

Tweak the threshold to control when the hash updates. For example:

  • A lower threshold (e.g., 50px) updates the hash when the section is near the top.
  • A higher threshold (e.g., 200px) updates it earlier (useful for tall sections).

For mobile, consider a larger threshold to account for smaller screens.

Intersection Observer API (Performance Boost)#

The scroll event + getBoundingClientRect() approach works but can be inefficient for many sections. The Intersection Observer API is a modern alternative that passively tracks when elements enter/exit the viewport.

Implement Intersection Observer:#

mounted() {  
  // Configure observer options  
  const observerOptions = {  
    root: null, // Use viewport as root  
    rootMargin: '0px 0px -80% 0px', // Trigger when 80% of section is visible  
    threshold: 0  
  };  
 
  // Create observer  
  const observer = new IntersectionObserver((entries) => {  
    entries.forEach(entry => {  
      if (entry.isIntersecting) {  
        const activeId = entry.target.getAttribute('id');  
        if (this.$route.hash !== `#${activeId}`) {  
          this.$router.replace({ hash: `#${activeId}` });  
        }  
      }  
    });  
  }, observerOptions);  
 
  // Observe all sections  
  document.querySelectorAll('section[id]').forEach(section => {  
    observer.observe(section);  
  });  
 
  // Store observer for cleanup  
  this.observer = observer;  
},  
beforeUnmount() {  
  this.observer.disconnect(); // Stop observing  
}  

Why This Works: The rootMargin: '0px 0px -80% 0px' means the observer triggers when the section’s top 20% enters the viewport (adjust -80% to control sensitivity).

Dynamic Content & Edge Cases#

  • Dynamic Sections: If sections are added/removed dynamically (e.g., via v-for), re-run the observer/scroll logic when the DOM updates (use this.$nextTick()).
  • Overlapping Sections: If multiple sections are in view (e.g., small screens), prioritize the section with the largest visible area (calculate via getBoundingClientRect()).
  • Smooth Scroll Conflicts: If using custom smooth scroll libraries, ensure they don’t interfere with Vue Router’s scrollBehavior.

Conclusion#

Syncing the URL hash with scroll position enhances user experience by making navigation intuitive and shareable. By combining scroll event detection (or Intersection Observer), active section identification, and Vue Router’s hash updates, you can create a polished, professional feel in your Vue app.

Adjust thresholds, use throttling/observers for performance, and test across devices to ensure compatibility.

References#