DEV Community

cn-2k
cn-2k

Posted on

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!

Top comments (0)