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.
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-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:
<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
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 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>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 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
scrollIntoViewas the method is aware of target'sscroll-margin-topproperty.
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.getElementById('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('MyContainer')
})
</script>License
MIT