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
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-scrollUsage
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
htmlelement:
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>3. Use isActive or activeId to style the active link:
๐ก 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
scrollIntoViewas the method is aware of target'sscroll-margin-topproperty.
License
MIT