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.

You may noticed that last targets may never intersect if entirely visible in the viewport. Clicking on their links highlights other links or does nothing. In addition to that, the URL hash may not reflect the active link.

But also, it's tricky to customize behavior according to different scroll interactions.

For example, you want to immediately highlight targets when scroll is originated from click but not when scroll is originated from wheel/touch.

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

Features

  • Precise and stable at any speed
  • CSS scroll-behavior and callback agnostic
  • Adaptive behavior on mount, back/forward navigation, scroll, click, cancel.
  • Customizable boundary offsets for each direction
  • Customizable behavior on top/bottom reached
  • Supports containers different than window

What it doesn't do?

  • Mutate elements and inject styles
  • 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:

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

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

<!-- Sidebar.vue -->

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

// Data used to render your 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>

๐Ÿ’ก 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. Configure 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.
rootId string | null null Id of the scrolling element. Set it only if your content is not scrolled by the window.
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 between any futher scroll/cancel interaction.
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 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 Scroll Animation
<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 set 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 on page mount

If using Nuxt, Vue Router is already configured to scroll to the URL hash on page load, back/forward navigation.

If not using Nuxt and you're setting up Vue Router from scratch, you must enable the feature manually:

const router = createRouter({
  // ...
  scrollBehavior(to) {
    if (to.hash) {
      return {
        el: to.hash
        // top: 100 // Eventual fixed header (overlayHeight)
      }
    }
  }
})

๐Ÿ’ก If you using native CSS scroll-behavior, adding the rule to your CSS is enough. No need to set it again in Vue Router.

Containers

If you want to scroll to a target inside a container, you must use scrollIntoView:

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

๐Ÿ’ก No need to set overlayHeight if using scrollIntoView as the method is aware of target's scroll-margin-top property.


License

MIT