From 6cce76685da39f8b5c75da047f847f82f70b9c4e Mon Sep 17 00:00:00 2001 From: Divyansh Singh <40380293+brc-dd@users.noreply.github.com> Date: Fri, 17 Apr 2026 12:53:10 +0530 Subject: [PATCH] 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. --- .gitignore | 1 + src/client/app/index.ts | 2 +- src/client/app/router.ts | 39 ++++--------------- src/client/app/utils.ts | 33 ---------------- src/client/index.ts | 1 - .../theme-default/composables/outline.ts | 9 +++-- .../styles/components/vp-doc.css | 3 ++ src/node/config.ts | 1 - src/node/siteConfig.ts | 14 ------- types/shared.d.ts | 5 --- 10 files changed, 18 insertions(+), 90 deletions(-) diff --git a/.gitignore b/.gitignore index 643310524..e6e95ca9e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ node_modules pnpm-global TODOs.md *.timestamp-*.mjs +.claude diff --git a/src/client/app/index.ts b/src/client/app/index.ts index 761c2d92f..178c98b1f 100644 --- a/src/client/app/index.ts +++ b/src/client/app/index.ts @@ -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) } }) }) diff --git a/src/client/app/router.ts b/src/client/app/router.ts index 7a36343aa..5d3d86f14 100644 --- a/src/client/app/router.ts +++ b/src/client/app/router.ts @@ -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 { 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 diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts index b734179c9..d63c5ee3a 100644 --- a/src/client/app/utils.ts +++ b/src/client/app/utils.ts @@ -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 -} diff --git a/src/client/index.ts b/src/client/index.ts index 6054daa47..cd7442e9f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -19,7 +19,6 @@ export { useRoute, useRouter } from './app/router' export { _escapeHtml, defineClientComponent, - getScrollOffset, inBrowser, onContentUpdated, withBase diff --git a/src/client/theme-default/composables/outline.ts b/src/client/theme-default/composables/outline.ts index 752ad15b1..669781f11 100644 --- a/src/client/theme-default/composables/outline.ts +++ b/src/client/theme-default/composables/outline.ts @@ -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 diff --git a/src/client/theme-default/styles/components/vp-doc.css b/src/client/theme-default/styles/components/vp-doc.css index 1c24374ac..b5b425967 100644 --- a/src/client/theme-default/styles/components/vp-doc.css +++ b/src/client/theme-default/styles/components/vp-doc.css @@ -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 { diff --git a/src/node/config.ts b/src/node/config.ts index 496495907..bdcd00e17 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -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 diff --git a/src/node/siteConfig.ts b/src/node/siteConfig.ts index 210bc3d01..a421ced3b 100644 --- a/src/node/siteConfig.ts +++ b/src/node/siteConfig.ts @@ -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 diff --git a/types/shared.d.ts b/types/shared.d.ts index bc8d28d46..cf40ea712 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -136,11 +136,6 @@ export interface SiteData { | 'force-auto' | (Omit & { initialValue?: 'dark' }) themeConfig: ThemeConfig - scrollOffset: - | number - | string - | string[] - | { selector: string | string[]; padding: number } locales: LocaleConfig localeIndex?: string contentProps?: Record