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
links
array contains objects for each section (e.g., "Features", "Pricing", "Testimonials"), with properties for thelabel
,target
URL (to), andactive
state. - The
activeHeadings
array is watched to determine which link should be highlighted. - When the page finishes loading (
page:finish
), thestartObservingHeadings
function 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)