Hey, What's up!? In this article I'll show you how to build a fancy menu that highlights the active section as you scroll with the power of Intersection Observer API and Nuxt.
✨ Demo: https://nuxt-startup-landing-page.vercel.app/ (scroll down the page and check the active state of the menu)
Requirements for a good understanding of this post:
- TailwindCSS
- Vuejs Class and Style Bindings
- Vuejs Composables
- Nuxt Basics
Step 1: Create the Scroll-Spy Logic
For this we will create a single Vuejs composable for tracking visible sections using the Intersection Observer API.
/composables/useScrollSpy.ts
export const useScrollspy = () => {
const observer = ref<IntersectionObserver>()
const visibleHeadings = ref<string[]>([])
const activeHeadings = ref<string[]>([])
const handleIntersection = (entries: IntersectionObserverEntry[]) => {
entries.forEach((entry) => {
const id = entry.target.id
if (entry.isIntersecting) {
visibleHeadings.value = [...visibleHeadings.value, id]
} else {
visibleHeadings.value = visibleHeadings.value.filter(h => h !== id)
}
})
}
const startObservingHeadings = (headings: Element[]) => {
headings.forEach((heading) => {
if (!observer.value) return
observer.value.observe(heading)
})
}
watch(visibleHeadings, (newHeadings) => {
if (newHeadings.length === 0) {
activeHeadings.value = []
history.replaceState(null, "", window.location.pathname)
} else {
activeHeadings.value = newHeadings
const activeSectionId = newHeadings[0]
if (activeSectionId) {
history.replaceState(null, "", `#${activeSectionId}`)
}
}
})
// Creating a instance of observer
onBeforeMount(() => {
observer.value = new IntersectionObserver(handleIntersection, {
threshold: 0.5
})
})
// Disconnecting the observer instance
onBeforeUnmount(() => {
observer.value?.disconnect()
})
return {
visibleHeadings,
activeHeadings,
startObservingHeadings
}
}
Where:
visibleHeadings: Stores the IDs of the currently visible sections of the page.
activeHeadings: Stores the ID of the currently active section, based on visibility.
handleIntersection Function: Updates the visibleHeadings array whenever an intersection occurs, adding or removing section IDs depending on visibility.
startObservingHeadings Function: Begins observing the provided headings (elements), using the IntersectionObserver.
watch Effect: listens to the visibleHeadings array and updates activeHeadings as well as the browser's history (history.replaceState) to reflect the active section in the URL. It also ensures that if no section is visible, the page URL is updated too.
Finally we return some resources to apply on Header component.
Step 2: Build the Menu Component
/components/HeaderComponent.vue
<template>
<header class="bg-gray-900/60 backdrop-blur border-b border-gray-800 -mb-px sticky top-0 z-[99999]">
<div class="mx-auto px-4 sm:px-6 lg:px-8 max-w-7xl flex items-center justify-center gap-3 h-16 lg:h-20">
<slot name="center">
<ul class="items-center gap-x-10 flex border shadow-sm border-gray-700 bg-gray-600/10 px-6 py-1.5 rounded-full">
<li
v-for="item in links"
:key="item.to"
class="relative"
>
<a
class="text-sm/6 font-semibold flex items-center hover:text-purple-600 gap-1 transition-colors"
:class="{
'text-purple-500': item.active,
'text-zinc-200': !item.active
}"
:href="item.to"
>
{{ item.label }}
</a>
</li>
</ul>
</slot>
</div>
</header>
</template>
<script setup lang="ts">
const nuxtApp = useNuxtApp()
const { activeHeadings, startObservingHeadings } = useScrollspy()
const links = computed(() => [
{
label: "Features",
to: "#features",
active: activeHeadings.value.includes("features") && !activeHeadings.value.includes("pricing")
},
{
label: "Pricing",
to: "#pricing",
active: activeHeadings.value.includes("pricing") && !activeHeadings.value.includes("features")
},
{
label: "Testimonials",
to: "#testimonials",
active: activeHeadings.value.includes("testimonials") && !activeHeadings.value.includes("pricing")
}
])
nuxtApp.hooks.hookOnce("page:finish", () => {
startObservingHeadings([
document.querySelector("#features"),
document.querySelector("#pricing"),
document.querySelector("#testimonials")
] as any)
})
</script>
The Header component above render our menu with some styles using TailwindCSS;
- The
linksarray contains objects for each section (e.g., "Features", "Pricing", "Testimonials"), with properties for thelabel,targetURL (to), andactivestate. - The
activeHeadingsarray is watched to determine which link should be highlighted. - When the page finishes loading (
page:finish), thestartObservingHeadingsfunction is called to observe the relevant sections (#features, #pricing, #testimonials), updating the active state as the user scrolls.
Futher info: nuxtApp.hooks.hookOnce is a method provided by Nuxt 3 that allows you to register a hook that will run only once during the lifecycle of the application. In this case we use this hook to call our function startObservingHeadings after the page is fully loaded, but only once.
Step 3: Add Content Sections
Finally you'll need to create your template sections and see the menu working:
/pages/index.vue (make sure to have a default Nuxt Layout created on /layouts folder)
<template>
<div>
<HeaderComponent />
<div class="p-10">
<section id="section1" class="h-screen">Section 1</section>
<section id="section2" class="h-screen">Section 2</section>
<section id="section3" class="h-screen">Section 3</section>
<section id="features" class="h-screen scroll-mt-28">Features</section>
<section id="pricing" class="h-screen scroll-mt-28">Pricing</section>
<section id="testimonials" class="h-screen scroll-mt-28">Testimonials</section>
</div>
</div>
</template>
<style>
html {
scroll-behavior: smooth
}
</style>
Note that we've used the scroll-mt class from TailwindCSS to control the scroll offset arround our sections when they are navigated via the menu. For example, clicking "Features" in the menu smoothly scrolls (through scroll-behavior: smooth class) to the Features section while applying a slight offset to ensure proper spacing around the section's content.
That's it! You can use, customize and make sure to adapt this menu for your use cases. Happy coding, Nuxter!
Top comments (0)