Logo

dev-resources.site

for different kinds of informations.

Creating a Scroll-Spy Menu with Nuxt 3 and Intersection Observer API

Published at
12/30/2024
Categories
vue
nuxt
typescript
frontend
Author
cn-2k
Categories
4 categories in total
vue
open
nuxt
open
typescript
open
frontend
open
Author
5 person written this
cn-2k
open
Creating a Scroll-Spy Menu with Nuxt 3 and Intersection Observer API

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 the label, target URL (to), and active state.
  • The activeHeadings array is watched to determine which link should be highlighted.
  • When the page finishes loading (page:finish), the startObservingHeadings 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>
Enter fullscreen mode Exit fullscreen mode

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!

nuxt Article's
30 articles in total
Favicon
Resolving Auto-Scroll issues for overflow container in a Nuxt app
Favicon
Nuxflare Auth: A lightweight self-hosted auth server built with Nuxt, Cloudflare and OpenAuth.js
Favicon
The easiest way to migrate from Nuxt 3 to Nuxt 4!
Favicon
Creating a Scroll-Spy Menu with Nuxt 3 and Intersection Observer API
Favicon
Nuxt
Favicon
How to add comment from BlueSky to static/vue/nuxt project
Favicon
Navigation guards in Nuxt 3 with defineNuxtRouteMiddleware
Favicon
2024 Nuxt3 Annual Ecosystem Summary🚀
Favicon
13 Vue Composables Tips You Need to Know
Favicon
Building a multi-lingual web app with Nuxt 3 and Nuxt i18n
Favicon
🚀 Fetching and Displaying Data in Nuxt 3 with useAsyncData
Favicon
Nuxt File Storage Module reaching 2K Downloads per Month
Favicon
Why you should use both v-if and v-show to toggle heavy components in Vue ?
Favicon
How to Access a Child Component’s Ref with multi-root node (Fragment) in Vue 3
Favicon
Deploying Nuxt.js app to GitHub pages
Favicon
Nuxt
Favicon
Add a Voice Search to your Nuxt3 App in 6 Easy Steps
Favicon
Nuxt.js in action: Vue.js server-side rendering framework
Favicon
@nuxt/test-utils - The First-Class Citizen for Nuxt Unit Testing
Favicon
Seamless Nuxt 2 Deployment: A Step-by-Step Guide with GitLab CI/CD and DigitalOcean
Favicon
Building Vhisper: Voice Notes App with AI Transcription and Post-Processing
Favicon
Easiest Way to Set Up GitHub Action CI/CD for Vue.js Apps
Favicon
Angular vs Next.js vs Nuxt.js: Choosing the Right Framework for Your Project
Favicon
Nuxt Authorization: How to Implement Team Role-Based Access Control in Nuxt 3
Favicon
Vue Fes Japan 2024
Favicon
💡 Building a Nuxt 3 App with Pinia and Testing It with Cypress 🚀
Favicon
Build a static website with Markdown content, using Nuxt and Fusionable (server API approach)
Favicon
Secure Your Nuxt 3 App
Favicon
Sending Emails in Nuxt 3: How I Handle Emails in My SaaS Boilerplate
Favicon
Build your new Storefront with Nuxt and Medusa 2.0.0

Featured ones: