Automatically update the navigation based on scroll position to indicate which section is currently active in the viewport.
Available in Other JavaScript Frameworks
CoreUI Vue Scrollspy Component is also available for Bootstrap and React. Explore framework-specific implementations below:
How it works
Added in v6.0.0<CScrollspy> watches the sections you list by id and hands the id of the current one to its default slot — the navigation stays yours, controlled through the active property like everywhere else in Vue. It is best used with a nav component or list group, but any anchor elements in the page work the same way. Here’s how it works.
- To start, scrollspy requires two things: a navigation, list group, or a simple set of links, plus a scrollable container. The scrollable container can be the viewport or a custom element with a set
heightandoverflow-y: scroll. - List the section ids in
ids, in document order, and pass the scrollable element throughcontainer(a template ref) when it is not the viewport. If there is no focusable element inside the container, be sure to also includetabindex="0"to ensure keyboard access. - As you scroll the container, the scoped default slot (
#default="{ activeId }") receives the id of the active section, andactivateis emitted whenever it changes. Ids must resolve to an element in the DOM, otherwise they’re ignored — a link<a href="#home">needs something like<div id="home">among the sections. - Sections are looked up when the component mounts and whenever
idsorcontainerchange; a section added later is picked up onceidschanges.
Detection
The component watches every section with an IntersectionObserver and decides which one is current from a single rule: an invisible activation line drawn across the scroll container, topMargin below its top edge (12% of the container’s height by default). The result depends only on where the container is scrolled to, never on which direction it got there.
- The active section is the lowest one whose top edge has moved up to or above the activation line. When several sections are visible at once, the one you have scrolled furthest into wins.
- Between two sections nothing changes. While the gap between one section’s end and the next heading crosses the line, the previous id stays active instead of the navigation going blank.
- Above the first section the first id is active, so the navigation is never empty once the container has content.
- At the bottom of the container the last id is active, even when the last section is too short for its top edge to ever reach the line.
Move the line with topMargin — a percentage of the container’s height or a pixel value, for example 96px to put it just below a sticky navbar; a pixel line is recomputed when the window resizes. To take over the observer’s root box entirely, set rootMargin instead; when both are given, rootMargin wins. See the API.
Scrollable containers and keyboard access
If you’re making a scrollable container (other than the viewport), be sure to have a height set and overflow-y: scroll; applied to it—alongside tabindex="0" to ensure keyboard access.
Examples
Navbar
Scroll the area below the navbar and watch the active state change. Open the dropdown and watch the dropdown items be highlighted as well — the toggle lights up whenever any of its items is the active one.
<template>
<CScrollspy :ids="['spyNavHeading1', 'spyNavHeading2', ...dropdownIds]" :container="container">
<template #default="{ activeId }">
<CNavbar class="bg-3 px-4">
<CNavbarBrand href="#">Navbar</CNavbarBrand>
<CNav variant="pills">
<CNavItem>
<CNavLink href="#spyNavHeading1" :active="activeId === 'spyNavHeading1'">
First
</CNavLink>
</CNavItem>
<CNavItem>
<CNavLink href="#spyNavHeading2" :active="activeId === 'spyNavHeading2'">
Second
</CNavLink>
</CNavItem>
<CDropdown variant="nav-item">
<CDropdownToggle
link
:class="activeId && dropdownIds.includes(activeId) ? 'active' : undefined"
>
Dropdown
</CDropdownToggle>
<CDropdownMenu>
<CDropdownItem href="#spyNavHeading3" :active="activeId === 'spyNavHeading3'">
Third
</CDropdownItem>
<CDropdownItem href="#spyNavHeading4" :active="activeId === 'spyNavHeading4'">
Fourth
</CDropdownItem>
<CDropdownItem href="#spyNavHeading5" :active="activeId === 'spyNavHeading5'">
Fifth
</CDropdownItem>
</CDropdownMenu>
</CDropdown>
</CNav>
</CNavbar>
<div ref="pane" class="overflow-auto p-4" style="max-height: 14rem" tabindex="0">
<h4 id="spyNavHeading1">First heading</h4>
<p>{{ copy }}</p>
<h4 id="spyNavHeading2">Second heading</h4>
<p>{{ copy }}</p>
<h4 id="spyNavHeading3">Third heading</h4>
<p>{{ copy }}</p>
<h4 id="spyNavHeading4">Fourth heading</h4>
<p>{{ copy }}</p>
<h4 id="spyNavHeading5">Fifth heading</h4>
<p>{{ copy }}</p>
</div>
</template>
</CScrollspy>
</template>
<script setup>
import { useTemplateRef } from 'vue'
import {
CDropdown,
CDropdownItem,
CDropdownMenu,
CDropdownToggle,
CNav,
CNavItem,
CNavLink,
CNavbar,
CNavbarBrand,
} from '@coreui/vue'
import { CScrollspy } from '@coreui/vue-pro'
const copy =
'This is some placeholder content for the scrollspy page. Note that as you scroll down the page, the appropriate navigation link is highlighted.'
const dropdownIds = ['spyNavHeading3', 'spyNavHeading4', 'spyNavHeading5']
const container = useTemplateRef('pane')
</script>Nested nav
Scrollspy also works with nested <CNav>s. The component reports one active id, so parent activation is yours to derive — a small map from parent to its section ids keeps the group’s link lit while any of its children is active. Scroll the area next to the navbar and watch the active state change.
<template>
<CScrollspy :ids="Object.values(groups).flat()" :container="container">
<template #default="{ activeId }">
<div class="row">
<div class="col-4">
<CNav class="flex-column" variant="pills">
<CNavLink href="#spyItem1" :active="parentOf(activeId) === 'spyItem1'">Item 1</CNavLink>
<CNav class="flex-column ms-4" variant="pills">
<CNavLink href="#spyItem1-1" :active="activeId === 'spyItem1-1'">Item 1-1</CNavLink>
<CNavLink href="#spyItem1-2" :active="activeId === 'spyItem1-2'">Item 1-2</CNavLink>
</CNav>
<CNavLink href="#spyItem2" :active="parentOf(activeId) === 'spyItem2'">Item 2</CNavLink>
<CNavLink href="#spyItem3" :active="parentOf(activeId) === 'spyItem3'">Item 3</CNavLink>
<CNav class="flex-column ms-4" variant="pills">
<CNavLink href="#spyItem3-1" :active="activeId === 'spyItem3-1'">Item 3-1</CNavLink>
<CNavLink href="#spyItem3-2" :active="activeId === 'spyItem3-2'">Item 3-2</CNavLink>
</CNav>
</CNav>
</div>
<div ref="pane" class="col-8 overflow-auto" style="max-height: 16rem" tabindex="0">
<h4 id="spyItem1">Item 1</h4>
<p>{{ copy }}</p>
<h5 id="spyItem1-1">Item 1-1</h5>
<p>{{ copy }}</p>
<h5 id="spyItem1-2">Item 1-2</h5>
<p>{{ copy }}</p>
<h4 id="spyItem2">Item 2</h4>
<p>{{ copy }}</p>
<h4 id="spyItem3">Item 3</h4>
<p>{{ copy }}</p>
<h5 id="spyItem3-1">Item 3-1</h5>
<p>{{ copy }}</p>
<h5 id="spyItem3-2">Item 3-2</h5>
<p>{{ copy }}</p>
</div>
</div>
</template>
</CScrollspy>
</template>
<script setup>
import { useTemplateRef } from 'vue'
import { CNav, CNavLink } from '@coreui/vue'
import { CScrollspy } from '@coreui/vue-pro'
const copy =
'This is some placeholder content for the scrollspy page. Note that as you scroll down the page, the appropriate navigation link is highlighted.'
const groups = {
spyItem1: ['spyItem1', 'spyItem1-1', 'spyItem1-2'],
spyItem2: ['spyItem2'],
spyItem3: ['spyItem3', 'spyItem3-1', 'spyItem3-2'],
}
const container = useTemplateRef('pane')
const parentOf = (id) =>
id ? Object.keys(groups).find((parent) => groups[parent].includes(id)) : undefined
</script>List group
Scrollspy also works with <CListGroup>s. Scroll the area next to the list group and watch the active state change.
<template>
<CScrollspy :ids="ids" :container="container">
<template #default="{ activeId }">
<div class="row">
<div class="col-4">
<CListGroup>
<CListGroupItem
v-for="(id, index) in ids"
:key="id"
as="a"
:href="`#${id}`"
:active="activeId === id"
>
Item {{ index + 1 }}
</CListGroupItem>
</CListGroup>
</div>
<div ref="pane" class="col-8 overflow-auto" style="max-height: 16rem" tabindex="0">
<template v-for="(id, index) in ids" :key="id">
<h4 :id="id">Item {{ index + 1 }}</h4>
<p>{{ copy }}</p>
</template>
</div>
</div>
</template>
</CScrollspy>
</template>
<script setup>
import { useTemplateRef } from 'vue'
import { CListGroup, CListGroupItem } from '@coreui/vue'
import { CScrollspy } from '@coreui/vue-pro'
const copy =
'This is some placeholder content for the scrollspy page. Note that as you scroll down the page, the appropriate navigation link is highlighted.'
const ids = ['spyList1', 'spyList2', 'spyList3', 'spyList4']
const container = useTemplateRef('pane')
</script>Simple anchors
Scrollspy is not limited to nav components and list groups, so it will work with any <a> anchor elements in the current document. Scroll the area and watch the highlighted link change.
<template>
<CScrollspy :ids="ids" :container="container">
<template #default="{ activeId }">
<div class="row">
<div class="col-4">
<div class="d-flex flex-column gap-3 text-center">
<a
v-for="(id, index) in ids"
:key="id"
:href="`#${id}`"
class="p-1 rounded"
:class="{ 'bg-subtle-primary': activeId === id }"
>
Item {{ index + 1 }}
</a>
</div>
</div>
<div ref="pane" class="col-8 overflow-auto" style="max-height: 16rem" tabindex="0">
<template v-for="(id, index) in ids" :key="id">
<h4 :id="id">Item {{ index + 1 }}</h4>
<p>{{ copy }}</p>
</template>
</div>
</div>
</template>
</CScrollspy>
</template>
<script setup>
import { useTemplateRef } from 'vue'
import { CScrollspy } from '@coreui/vue-pro'
const copy =
'This is some placeholder content for the scrollspy page. Note that as you scroll down the page, the appropriate navigation link is highlighted.'
const ids = ['spyAnchor1', 'spyAnchor2', 'spyAnchor3', 'spyAnchor4', 'spyAnchor5']
const container = useTemplateRef('pane')
</script>API
Check out the documentation below for a comprehensive guide to all the props you can use with the components mentioned here.