JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 2226
  • Score
    100M100P100Q116346F
  • License MIT

Reactive and accurate TOC/sidebar links without compromises for Vue 3.

Package Exports

  • vue-use-active-scroll
  • vue-use-active-scroll/dist/index.js
  • vue-use-active-scroll/dist/index.mjs

This package does not declare an exports field, so the exports above have been automatically detected and optimized by JSPM instead. If any package subpath is missing, it is recommended to post an issue to the original package (vue-use-active-scroll) to support the "exports" field. If that is not possible, create a JSPM override to customize the exports field for this package.

Readme

npm GitHub Workflow Status dependency-count

Vue Use Active Scroll

Examples: Vite: Demo App β€” Nuxt Content: Nested TOC

πŸ’‘ Requires Vue 3 or above.


Why?

The Intersection Observer is a great API. But it may not be the one-size-fits-all solution to highlight menu/sidebar links.

When smooth-scrolling, you may want to immediately highlight targets when scroll is originated from click/navigation but not when it is originated from wheel/touch. You may also want to highlight any clicked link even if it will never intersect.

Vue Use Active Scroll implements a custom scroll observer which automatically adapts to any type of scroll behaviors and interactions and always returns the "correct" active target.

Features

  • Precise and stable at any speed
  • CSS scroll-behavior or JS scroll agnostic
  • Adaptive behavior on mount, back/forward hash navigation, scroll, click, cancel.
  • Customizable boundary offsets for each direction
  • Customizable offsets for first/last targets
  • Customizable behavior on top/bottom reached
  • Supports custom scrolling containers

What it doesn't do?

  • Mutate elements and inject styles
  • Force specific scroll behavior / callbacks
  • Scroll to targets

Installation

pnpm add vue-use-active-scroll

Usage

1. Provide target IDs

Assuming your content looks like:

<h2 id="introduction">Introduction</h2>
<p>...</p>
<h2 id="quick-start">Quick Start</h2>
<p>...</p>
<h2 id="props">Props</h2>
<p>...</p>

And your links look like:

<a href="#introduction">Introduction</a>
<a href="#quick-start">Quick Start</a>
<a href="#props">Props</a>

In your menu/sidebar component, provide the IDs to observe to useActive (order is not important).

<!-- Sidebar.vue -->

<script setup>
import { useActive } from 'vue-use-active-scroll'

// Data to render links
const links = ref([
  { href: 'introduction', label: 'Introduction' },
  { href: 'quick-start', label: 'Quick Start' },
  { href: 'props', label: 'Props' }
])

const targets = computed(() => links.map(({ href }) => href))
// console.log(targets.value) => ['introduction', 'quick-start', 'props']

const { isActive } = useActive(targets)
</script>

You can provide either a reactive or a plain array of strings. If the array is reactive, the observer will reinitialize whenever it changes.

πŸ’‘ For a TOC, you want to target (and scroll) the headings of your sections (instead of the whole section) to ensure results better-aligned with users' reading flow.

Nuxt Content 2

Nuxt Content automatically applies IDs to your headings. If enabled the document-driven mode you can directly query the TOC in your sidebar component:

const { toc } = useContent()

Then just compute the array of the IDs to observe (assuming max depth is 3):

const targets = computed(() =>
  toc.value.links.flatMap(({ id, children = [] }) => [
    id,
    ...children.map(({ id }) => id)
  ])
)

const { setActive, isActive } = useActive(targets)
Without Document-driven
const { data } = await useAsyncData('about', () =>
  queryContent('/about').findOne()
)

const targets = computed(() =>
  data.value
    ? data.value.body.toc.links.flatMap(({ id, children = [] }) => [
        id,
        ...children.map(({ id }) => id)
      ])
    : []
)

const { isActive } = useActive(targets)

2. Customize the composable (optional)

useActive accepts an optional configuration object as its second argument:

const { isActive, setActive } = useActive(targets, {
  // ...
})
Property Type Default Description
jumpToFirst boolean true Whether to set the first target on mount as active even if not (yet) intersecting.
jumpToLast boolean true Whether to set the last target as active once reached the bottom even if previous targets are entirely visible.
boundaryOffset BoundaryOffset { toTop: 0, toBottom: 0 } Boundary offset in px for each scroll direction. Tweak them to "anticipate" or "delay" target detection.
edgeOffset EdgeOffset { first: 100, last: -100 } Offset in px for fist and last target. first has no effect if jumpToFirst is true. Same for last if jumpToLast is true.
root HTMLElement | null | Ref<HTMLElement | null> null Scrolling element. Set it only if your content is not scrolled by the window. If null, defaults to documentElement.
replaceHash boolean false Whether to replace URL hash on scroll. First target is ignored if jumpToFirst is true.
overlayHeight number 0 Height in pixels of any CSS fixed content that overlaps the top of your scrolling area (e.g. fixed header). Must be paired with a CSS scroll-margin-top rule.
minWidth number 0 Whether to toggle listeners and functionalities within a specific width. Useful if hiding the sidebar using display: none.

Return object

Name Type Description
setActive (id: string) => void 🧨 Function to include in your click handler to ensure adaptive behavior.
isActive (id: string) => boolean Whether the given Id is active or not
activeId Ref<string> Id of the active target
activeIndex Ref<number> Index of the active target in offset order, 0 for the first target and so on.

3. Create your sidebar

1. Call setActive in your click handler by passing the anchor ID

<!-- Sidebar.vue -->

<script setup>
// ...

const { isActive, setActive } = useActive(targets)
</script>

<template>
  <nav>
    <a
      @click="setActive(link.href) /* πŸ‘ˆπŸ» */"
      v-for="(link, index) in links"
      :key="link.href"
      :href="`#${link.href}`"
    >
      {{ link.label }}
    </a>
  </nav>
</template>

2. Define scroll behavior

You're free to choose between CSS (smooth or auto), scrollIntoView or even a library like animated-scroll-to.

A. Using native CSS scroll-behavior

  • If content is scrolled by the window, add the following CSS rule to your html element:
html {
  scroll-behavior: smooth; /* or 'auto' */
}
  • If content is scrolled by a container:
html {
  scroll-behavior: auto; /* Keep it 'auto' */
}

.container {
  scroll-behavior: smooth;
}
B. Custom JS Scroll
<script setup>
import { useActive } from 'vue-use-active-scroll'
import animateScrollTo from 'animated-scroll-to'

// ...

const { isActive, setActive } = useActive(targets)

function scrollTo(event, id) {
  // ...
  setActive(id) // πŸ‘ˆπŸ» Include setActive
  animateScrollTo(document.getElementById(id), {
    easing: easeOutBack,
    minDuration: 300,
    maxDuration: 600
  })
}
</script>

<template>
  <!-- ... -->
  <a
    v-for="(link, index) in links"
    @click="scrollTo($event, link.href)"
    :key="link.href"
    :href="`#${link.href}`"
  >
    {{ link.label }}
  </a>
  <!-- ... -->
</template>

πŸ’‘ If you're playing with transitions simply leverage activeIndex.

<script setup>
// ...

const { isActive, setActive } = useActive(targets)
</script>

<template>
  <nav>
    <a
      @click="setActive(link.href)"
      v-for="(link, index) in links"
      :key="link.href"
      :href="`#${link.href}`"
      :class="{
        active: isActive(link.href) /* πŸ‘ˆπŸ» or link.href === activeId */
      }"
    >
      {{ link.label }}
    </a>
  </nav>
</template>

<style>
html {
  /* or .container { */
  scroll-behavior: smooth; /* or 'auto' */
}

.active {
  color: #f00;
}
</style>
RouterLink
<RouterLink
  @click.native="setActive(link.href)"
  :to="{ hash: `#${link.href}` }"
  :class="{
    active: isActive(link.href)
  }"
  :ariaCurrentValue="`${isActive(link.href)}`"
  activeClass=""
  exactActiveClass=""
>
  {{ link.label }}
</RouterLink>
NuxtLink
<NuxtLink
  @click="setActive(link.href)"
  :href="`#${link.href}`"
  :class="{
    active: isActive(link.href)
  }"
>
  {{ link.label }}
</NuxtLink>

Setting scroll-margin-top for fixed headers

You might noticed that if you have a fixed header and defined an overlayHeight, once you click to scroll to a target it may be underneath the header. You must add scroll-margin-top to your targets:

useActive(targets, { overlayHeight: 100 })
.target {
  scroll-margin-top: 100px; /* Add overlayHeight to scroll-margin-top */
}

Vue Router - Scroll to hash onMount / navigation

⚠️ If using Nuxt 3, Vue Router is already configured to scroll to and from URL hash on page load or back/forward navigation. So you don't need to do follow the steps below. Otherwise rules must be defined manually.

Scrolling to hash

For content scrolled by the window, simply return the target element. To scroll to a target scrolled by a container, use scrollIntoView method.

const router = createRouter({
  // ...
  scrollBehavior(to) {
    if (to.hash) {
      // Content scrolled by a container
      if (to.name === 'PageNameUsingContainer') {
        return document.querySelector(to.hash).scrollIntoView()
      }

      // Content scrolled by the window
      return {
        el: to.hash
        // top: 100 // Eventual fixed header (overlayHeight)
      }
    }
  }
})

πŸ’‘ There's no need to define smooth or auto here. Adding the CSS rule is enough.

πŸ’‘ There's no need need to set overlayHeight if using scrollIntoView as the method is aware of target's scroll-margin-top property.

Scrolling from hash back to the top of the page

To navigate back to the top of the same page (e.g. clicking on browser back button from a hash to the page root), use the scroll method for containers and return top for content scrolled by the window.

const router = createRouter({
  // ...
  scrollBehavior(to, from) {
    if (from.hash && !to.hash) {
      // Content scrolled by a container
      if (
        to.name === 'PageNameUsingContainer' &&
        from.name === 'PageNameUsingContainer'
      ) {
        return document.querySelector('#ScrollingContainer').scroll(0, 0)
      }

      // Content scrolled by the window
      return { top: 0 }
    }
  }
})

Vue Router - Prevent hash from being pushed

You may noticed that when clicking on a link, a new entry is added to the history. When navigating back, the page will scroll to the previous target and so on.

If you don't like that, choose to replace instead of pushing the hash:

<template>
  <!-- ... -->
  <RouterLink
    @click.native="setActive(link.href)"
    :to="{ hash: `#${item.href}`, replace: true /* πŸ‘ˆπŸ» */ }"
    :class="{
      active: isActive(link.href)
    }"
  />
  <!-- ... -->
</template>

Custom initialization / re-initialization

If the targets array is empty, useActive won't initialize the scroll observer.

Whenever rootΒ or targets are updated (and not empty), useActive will re-initialize the observer.

<script setup>
// ...

const targets = ref([])
const root = ref(null)

const { isActive, setActive } = useActive(targets) // Nothing is initialized

watch(someReactiveValue, async (newValue) => {
  await someAsyncFunction()

  // Whenever ready, update targets or root and init
  targets.value = ['id-1', 'id-2', 'id-3']
  root.value = document.getElementById('my_container')
})
</script>

License

MIT