feat!: support scroll-margin / scroll-padding

Replaces the JS-based scroll offset logic with native CSS `scroll-margin-top`. The default theme sets it on headings using `--vp-nav-height` and `--vp-layout-top-height`, and `scrollTo` now uses `scrollIntoView` which respects it natively.

BREAKING CHANGE: `scrollOffset` from config is removed. Users wanting to customize scroll offset should customize `scroll-margin-top` via CSS instead. `smoothScroll` support from `router.go` is also removed as it didn't work as expected for most users. Users wanting smooth scrolling should set `scroll-behavior: smooth` in CSS, ideally inside a `@media (prefers-reduced-motion: no-preference)` block.
pull/5186/head
Divyansh Singh 3 months ago
parent 9da1e3e70f
commit 6cce76685d

1
.gitignore vendored

@ -16,3 +16,4 @@ node_modules
pnpm-global
TODOs.md
*.timestamp-*.mjs
.claude

@ -168,7 +168,7 @@ if (inBrowser) {
// scroll to hash on new tab during dev
if (import.meta.env.DEV && location.hash) {
scrollTo(location.hash)
setTimeout(() => scrollTo(location.hash), 100)
}
})
})

@ -3,7 +3,7 @@ import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
import type { Awaitable, PageData, PageDataPayload } from '../shared'
import { notFoundPageData, treatAsHtml } from '../shared'
import { siteDataRef } from './data'
import { getScrollOffset, inBrowser, withBase } from './utils'
import { inBrowser, withBase } from './utils'
export interface Route {
path: string
@ -26,8 +26,6 @@ export interface Router {
options?: {
// @internal
initialLoad?: boolean
// Whether to smoothly scroll to the target position.
smoothScroll?: boolean
// Whether to replace the current history entry.
replace?: boolean
}
@ -145,7 +143,7 @@ export function createRouter(
history.replaceState({}, '', href)
}
if (!initialLoad) scrollTo(targetLoc.hash, false, scrollPosition)
if (!initialLoad) scrollTo(targetLoc.hash, scrollPosition)
})
}
}
@ -232,10 +230,7 @@ export function createRouter(
// only intercept inbound html links
if (origin === currentLoc.origin && treatAsHtml(pathname)) {
e.preventDefault()
router.go(href, {
// use smooth scroll when clicking on header anchor links
smoothScroll: link.classList.contains('header-anchor')
})
router.go(href)
}
},
{ capture: true }
@ -270,7 +265,7 @@ export function useRoute(): Route {
return useRouter().route
}
export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
export function scrollTo(hash: string, scrollPosition = 0) {
if (!hash || scrollPosition) {
window.scrollTo(0, scrollPosition)
return
@ -284,21 +279,8 @@ export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
}
if (!target) return
const targetTop =
window.scrollY +
target.getBoundingClientRect().top -
getScrollOffset() +
Number.parseInt(window.getComputedStyle(target).paddingTop, 10) || 0
const behavior = window.matchMedia('(prefers-reduced-motion)').matches
? 'instant'
: // only smooth scroll if distance is smaller than screen height
smooth && Math.abs(targetTop - window.scrollY) <= window.innerHeight
? 'smooth'
: 'auto'
const scrollToTarget = () => {
window.scrollTo({ left: 0, top: targetTop, behavior })
target.scrollIntoView({ block: 'start' })
// focus the target element for better accessibility
target.focus({ preventScroll: true })
@ -361,12 +343,7 @@ function normalizeHref(href: string): string {
async function changeRoute(
href: string,
{
smoothScroll = false,
initialLoad = false,
replace = false,
hasTextFragment = false
} = {}
{ initialLoad = false, replace = false, hasTextFragment = false } = {}
): Promise<boolean> {
const loc = normalizeHref(location.href)
const nextUrl = new URL(href, location.origin)
@ -374,7 +351,7 @@ async function changeRoute(
if (href === loc) {
if (!initialLoad) {
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
if (!hasTextFragment) scrollTo(nextUrl.hash)
return false
}
} else {
@ -395,7 +372,7 @@ async function changeRoute(
newURL: nextUrl.href
})
)
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
if (!hasTextFragment) scrollTo(nextUrl.hash)
}
return false

@ -102,36 +102,3 @@ export function defineClientComponent(
}
}
}
export function getScrollOffset() {
let scrollOffset = siteDataRef.value.scrollOffset
let offset = 0
let padding = 24
if (typeof scrollOffset === 'object' && 'padding' in scrollOffset) {
padding = scrollOffset.padding
scrollOffset = scrollOffset.selector
}
if (typeof scrollOffset === 'number') {
offset = scrollOffset
} else if (typeof scrollOffset === 'string') {
offset = tryOffsetSelector(scrollOffset, padding)
} else if (Array.isArray(scrollOffset)) {
for (const selector of scrollOffset) {
const res = tryOffsetSelector(selector, padding)
if (res) {
offset = res
break
}
}
}
return offset
}
function tryOffsetSelector(selector: string, padding: number): number {
const el = document.querySelector(selector)
if (!el) return 0
const bot = el.getBoundingClientRect().bottom
if (bot < 0) return 0
return bot + padding
}

@ -19,7 +19,6 @@ export { useRoute, useRouter } from './app/router'
export {
_escapeHtml,
defineClientComponent,
getScrollOffset,
inBrowser,
onContentUpdated,
withBase

@ -1,4 +1,3 @@
import { getScrollOffset } from 'vitepress'
import type { DefaultTheme } from 'vitepress/theme'
import { onMounted, onUnmounted, onUpdated, type Ref } from 'vue'
import { throttleAndDebounce } from '../support/utils'
@ -115,7 +114,9 @@ export function useActiveAnchor(
const headers = resolvedHeaders
.map(({ element, link }) => ({
link,
top: getAbsoluteTop(element)
top: getAbsoluteTop(element),
scrollMarginTop:
Number.parseFloat(getComputedStyle(element).scrollMarginTop) || 0
}))
.filter(({ top }) => !Number.isNaN(top))
.sort((a, b) => a.top - b.top)
@ -140,8 +141,8 @@ export function useActiveAnchor(
// find the last header above the top of viewport
let activeLink: string | null = null
for (const { link, top } of headers) {
if (top > scrollY + getScrollOffset() + 4) {
for (const { link, top, scrollMarginTop } of headers) {
if (top > scrollY + scrollMarginTop + 4) {
break
}
activeLink = link

@ -11,6 +11,9 @@
position: relative;
font-weight: 600;
outline: none;
scroll-margin-top: calc(
var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 24px
);
}
.vp-doc h1 {

@ -335,7 +335,6 @@ export async function resolveSiteData(
appearance: userConfig.appearance ?? true,
themeConfig: userConfig.themeConfig || {},
locales: userConfig.locales || {},
scrollOffset: userConfig.scrollOffset ?? 134,
cleanUrls: !!userConfig.cleanUrls,
contentProps: userConfig.contentProps,
additionalConfig: userConfig.additionalConfig

@ -81,20 +81,6 @@ export interface UserConfig<
*/
vite?: ViteConfig & { configFile?: string | false }
/**
* Configure the scroll offset when the theme has a sticky header.
* Can be a number or a selector element to get the offset from.
* Can also be an array of selectors in case some elements will be
* invisible due to responsive layout. VitePress will fallback to the next
* selector if a selector fails to match, or the matched element is not
* currently visible in viewport.
*/
scrollOffset?:
| number
| string
| string[]
| { selector: string | string[]; padding: number }
/**
* Enable MPA / zero-JS mode.
* @experimental

5
types/shared.d.ts vendored

@ -136,11 +136,6 @@ export interface SiteData<ThemeConfig = any> {
| 'force-auto'
| (Omit<UseDarkOptions, 'initialValue'> & { initialValue?: 'dark' })
themeConfig: ThemeConfig
scrollOffset:
| number
| string
| string[]
| { selector: string | string[]; padding: number }
locales: LocaleConfig<ThemeConfig>
localeIndex?: string
contentProps?: Record<string, any>

Loading…
Cancel
Save