Table of Contents#
- Prerequisites
- Understanding the Problem
- Step-by-Step Implementation
- Advanced Considerations
- Conclusion
- References
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 createor 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:
- Detect when the user scrolls.
- Determine which section is currently in view.
- 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().topreturns the section’s distance from the top of the viewport.- The
thresholdensures the section is "close enough" to the top (e.g., 100px) before updating the hash. section.offsetHeightaccounts 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’sscrollBehavior(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 (usethis.$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.