Skip to content

Commit 6cce766

Browse files
committed
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.
1 parent 9da1e3e commit 6cce766

File tree

10 files changed

+18
-90
lines changed

10 files changed

+18
-90
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,3 +16,4 @@ node_modules
1616
pnpm-global
1717
TODOs.md
1818
*.timestamp-*.mjs
19+
.claude

src/client/app/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ if (inBrowser) {
168168

169169
// scroll to hash on new tab during dev
170170
if (import.meta.env.DEV && location.hash) {
171-
scrollTo(location.hash)
171+
setTimeout(() => scrollTo(location.hash), 100)
172172
}
173173
})
174174
})

src/client/app/router.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { inject, markRaw, nextTick, reactive, readonly } from 'vue'
33
import type { Awaitable, PageData, PageDataPayload } from '../shared'
44
import { notFoundPageData, treatAsHtml } from '../shared'
55
import { siteDataRef } from './data'
6-
import { getScrollOffset, inBrowser, withBase } from './utils'
6+
import { inBrowser, withBase } from './utils'
77

88
export interface Route {
99
path: string
@@ -26,8 +26,6 @@ export interface Router {
2626
options?: {
2727
// @internal
2828
initialLoad?: boolean
29-
// Whether to smoothly scroll to the target position.
30-
smoothScroll?: boolean
3129
// Whether to replace the current history entry.
3230
replace?: boolean
3331
}
@@ -145,7 +143,7 @@ export function createRouter(
145143
history.replaceState({}, '', href)
146144
}
147145

148-
if (!initialLoad) scrollTo(targetLoc.hash, false, scrollPosition)
146+
if (!initialLoad) scrollTo(targetLoc.hash, scrollPosition)
149147
})
150148
}
151149
}
@@ -232,10 +230,7 @@ export function createRouter(
232230
// only intercept inbound html links
233231
if (origin === currentLoc.origin && treatAsHtml(pathname)) {
234232
e.preventDefault()
235-
router.go(href, {
236-
// use smooth scroll when clicking on header anchor links
237-
smoothScroll: link.classList.contains('header-anchor')
238-
})
233+
router.go(href)
239234
}
240235
},
241236
{ capture: true }
@@ -270,7 +265,7 @@ export function useRoute(): Route {
270265
return useRouter().route
271266
}
272267

273-
export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
268+
export function scrollTo(hash: string, scrollPosition = 0) {
274269
if (!hash || scrollPosition) {
275270
window.scrollTo(0, scrollPosition)
276271
return
@@ -284,21 +279,8 @@ export function scrollTo(hash: string, smooth = false, scrollPosition = 0) {
284279
}
285280
if (!target) return
286281

287-
const targetTop =
288-
window.scrollY +
289-
target.getBoundingClientRect().top -
290-
getScrollOffset() +
291-
Number.parseInt(window.getComputedStyle(target).paddingTop, 10) || 0
292-
293-
const behavior = window.matchMedia('(prefers-reduced-motion)').matches
294-
? 'instant'
295-
: // only smooth scroll if distance is smaller than screen height
296-
smooth && Math.abs(targetTop - window.scrollY) <= window.innerHeight
297-
? 'smooth'
298-
: 'auto'
299-
300282
const scrollToTarget = () => {
301-
window.scrollTo({ left: 0, top: targetTop, behavior })
283+
target.scrollIntoView({ block: 'start' })
302284

303285
// focus the target element for better accessibility
304286
target.focus({ preventScroll: true })
@@ -361,20 +343,15 @@ function normalizeHref(href: string): string {
361343

362344
async function changeRoute(
363345
href: string,
364-
{
365-
smoothScroll = false,
366-
initialLoad = false,
367-
replace = false,
368-
hasTextFragment = false
369-
} = {}
346+
{ initialLoad = false, replace = false, hasTextFragment = false } = {}
370347
): Promise<boolean> {
371348
const loc = normalizeHref(location.href)
372349
const nextUrl = new URL(href, location.origin)
373350
const currentUrl = new URL(loc, location.origin)
374351

375352
if (href === loc) {
376353
if (!initialLoad) {
377-
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
354+
if (!hasTextFragment) scrollTo(nextUrl.hash)
378355
return false
379356
}
380357
} else {
@@ -395,7 +372,7 @@ async function changeRoute(
395372
newURL: nextUrl.href
396373
})
397374
)
398-
if (!hasTextFragment) scrollTo(nextUrl.hash, smoothScroll)
375+
if (!hasTextFragment) scrollTo(nextUrl.hash)
399376
}
400377

401378
return false

src/client/app/utils.ts

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -102,36 +102,3 @@ export function defineClientComponent(
102102
}
103103
}
104104
}
105-
106-
export function getScrollOffset() {
107-
let scrollOffset = siteDataRef.value.scrollOffset
108-
let offset = 0
109-
let padding = 24
110-
if (typeof scrollOffset === 'object' && 'padding' in scrollOffset) {
111-
padding = scrollOffset.padding
112-
scrollOffset = scrollOffset.selector
113-
}
114-
if (typeof scrollOffset === 'number') {
115-
offset = scrollOffset
116-
} else if (typeof scrollOffset === 'string') {
117-
offset = tryOffsetSelector(scrollOffset, padding)
118-
} else if (Array.isArray(scrollOffset)) {
119-
for (const selector of scrollOffset) {
120-
const res = tryOffsetSelector(selector, padding)
121-
if (res) {
122-
offset = res
123-
break
124-
}
125-
}
126-
}
127-
128-
return offset
129-
}
130-
131-
function tryOffsetSelector(selector: string, padding: number): number {
132-
const el = document.querySelector(selector)
133-
if (!el) return 0
134-
const bot = el.getBoundingClientRect().bottom
135-
if (bot < 0) return 0
136-
return bot + padding
137-
}

src/client/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ export { useRoute, useRouter } from './app/router'
1919
export {
2020
_escapeHtml,
2121
defineClientComponent,
22-
getScrollOffset,
2322
inBrowser,
2423
onContentUpdated,
2524
withBase

src/client/theme-default/composables/outline.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { getScrollOffset } from 'vitepress'
21
import type { DefaultTheme } from 'vitepress/theme'
32
import { onMounted, onUnmounted, onUpdated, type Ref } from 'vue'
43
import { throttleAndDebounce } from '../support/utils'
@@ -115,7 +114,9 @@ export function useActiveAnchor(
115114
const headers = resolvedHeaders
116115
.map(({ element, link }) => ({
117116
link,
118-
top: getAbsoluteTop(element)
117+
top: getAbsoluteTop(element),
118+
scrollMarginTop:
119+
Number.parseFloat(getComputedStyle(element).scrollMarginTop) || 0
119120
}))
120121
.filter(({ top }) => !Number.isNaN(top))
121122
.sort((a, b) => a.top - b.top)
@@ -140,8 +141,8 @@ export function useActiveAnchor(
140141

141142
// find the last header above the top of viewport
142143
let activeLink: string | null = null
143-
for (const { link, top } of headers) {
144-
if (top > scrollY + getScrollOffset() + 4) {
144+
for (const { link, top, scrollMarginTop } of headers) {
145+
if (top > scrollY + scrollMarginTop + 4) {
145146
break
146147
}
147148
activeLink = link

src/client/theme-default/styles/components/vp-doc.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
position: relative;
1212
font-weight: 600;
1313
outline: none;
14+
scroll-margin-top: calc(
15+
var(--vp-nav-height) + var(--vp-layout-top-height, 0px) + 24px
16+
);
1417
}
1518

1619
.vp-doc h1 {

src/node/config.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -335,7 +335,6 @@ export async function resolveSiteData(
335335
appearance: userConfig.appearance ?? true,
336336
themeConfig: userConfig.themeConfig || {},
337337
locales: userConfig.locales || {},
338-
scrollOffset: userConfig.scrollOffset ?? 134,
339338
cleanUrls: !!userConfig.cleanUrls,
340339
contentProps: userConfig.contentProps,
341340
additionalConfig: userConfig.additionalConfig

src/node/siteConfig.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -81,20 +81,6 @@ export interface UserConfig<
8181
*/
8282
vite?: ViteConfig & { configFile?: string | false }
8383

84-
/**
85-
* Configure the scroll offset when the theme has a sticky header.
86-
* Can be a number or a selector element to get the offset from.
87-
* Can also be an array of selectors in case some elements will be
88-
* invisible due to responsive layout. VitePress will fallback to the next
89-
* selector if a selector fails to match, or the matched element is not
90-
* currently visible in viewport.
91-
*/
92-
scrollOffset?:
93-
| number
94-
| string
95-
| string[]
96-
| { selector: string | string[]; padding: number }
97-
9884
/**
9985
* Enable MPA / zero-JS mode.
10086
* @experimental

types/shared.d.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -136,11 +136,6 @@ export interface SiteData<ThemeConfig = any> {
136136
| 'force-auto'
137137
| (Omit<UseDarkOptions, 'initialValue'> & { initialValue?: 'dark' })
138138
themeConfig: ThemeConfig
139-
scrollOffset:
140-
| number
141-
| string
142-
| string[]
143-
| { selector: string | string[]; padding: number }
144139
locales: LocaleConfig<ThemeConfig>
145140
localeIndex?: string
146141
contentProps?: Record<string, any>

0 commit comments

Comments
 (0)