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
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 want to:
- Highlight any clicked link even if it will never intersect
- Get consistent results regardless of scroll speed
- Immediately highlight links on click/hash navigation if smooth scrolling is enabled
- Prevent unnatural highlighting with custom easings or smooth scrolling
Vue Use Active Scroll implements a custom scroll observer which automatically adapts to any type of scroll behavior and interaction 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 offsets for each scroll direction
- Customizable offsets for first and last target
- 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 will 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).
๐ก For a TOC, you may want to target (and scroll) the headings of your sections (instead of the whole section) to ensure results better-aligned with users' reading flow.
<!-- Sidebar.vue -->
<script setup>
import { useActive } from 'vue-use-active-scroll'
// Data to render links, in real life you may pass them as prop, use inject() etc...
const links = ref([
{ href: 'introduction', label: 'Introduction' },
{ href: 'quick-start', label: 'Quick Start' },
{ href: 'props', label: 'Props' }
])
const targets = computed(() => links.value.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.
If an empty array is provided, the observer won't be initialized until the array is populated.
What if my targets don't have IDs?
There might be cases where you lack control over the rendered HTML and no IDs nor TOC are provided. Assuming your content is wrapped by container with an ID (e.g. #ArticleContent):
<!-- Sidebar.vue -->
<script setup>
const links = ref([])
onMounted(() => {
// 1. Get all the targets
const targets = Array.from(
document.getElementById('ArticleContent').querySelectorAll('h2')
)
targets.forEach((target) => {
// 2. Generate an ID from their text content and add it
target.id = target.textContent.toLowerCase().replace(/\s+/g, '-')
// 3. Populate the array to render links
links.value.push({
href: target.id,
label: target.textContent
})
})
})
// 4. Provide the IDs to observe
const targets = computed(() => links.value.map(({ href }) => href))
const { isActive } = useActive(targets)
</script>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="{
ActiveLink: isActive(link.href) /* ๐๐ป or link.href === activeId */
}"
>
{{ link.label }}
</a>
</nav>
</template>
<style>
html {
/* or .container { */
scroll-behavior: smooth; /* or 'auto' */
}
.ActiveLink {
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 clicked to scroll, the target 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 on mount / 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>License
MIT