feat(navigation): improve hash anchor scrolling with header-aware positioning and visual feedback

- Add scrollToElementWithBuffer and highlightElement utility functions to util.ts
- Implement header-aware scroll positioning to prevent content hiding behind fixed headers
- Add temporary highlight effect for scrolled-to elements to improve user experience
- Refactor SPA and popover scripts to use shared utility functions (DRY principle)
- Support configurable buffer space and highlight duration for different use cases

Fixes content being obscured by page header when navigating to hash anchors.
Enhances UX with visual feedback showing which heading was targeted.
This commit is contained in:
neerajadhav 2025-07-07 18:29:27 +05:30
parent 467896413f
commit c803941cdd
3 changed files with 93 additions and 7 deletions

View File

@ -1,6 +1,7 @@
import { computePosition, flip, inline, shift } from "@floating-ui/dom" import { computePosition, flip, inline, shift } from "@floating-ui/dom"
import { fetchCanonical, scrollInContainerToElement } from "./util"
import { normalizeRelativeURLs } from "../../util/path" import { normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util"
const p = new DOMParser() const p = new DOMParser()
let activeAnchor: HTMLAnchorElement | null = null let activeAnchor: HTMLAnchorElement | null = null
@ -33,8 +34,8 @@ async function mouseEnterHandler(
const targetAnchor = `#popover-internal-${hash.slice(1)}` const targetAnchor = `#popover-internal-${hash.slice(1)}`
const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null const heading = popoverInner.querySelector(targetAnchor) as HTMLElement | null
if (heading) { if (heading) {
// leave ~12px of buffer when scrolling to a heading // Use utility function to scroll with buffer and highlight
popoverInner.scroll({ top: heading.offsetTop - 12, behavior: "instant" }) scrollInContainerToElement(popoverInner, heading, 20, true, "instant")
} }
} }
} }

View File

@ -1,6 +1,7 @@
import micromorph from "micromorph"
import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path" import { FullSlug, RelativeURL, getFullSlug, normalizeRelativeURLs } from "../../util/path"
import { fetchCanonical } from "./util" import { fetchCanonical, scrollToElementWithBuffer } from "./util"
import micromorph from "micromorph"
// adapted from `micromorph` // adapted from `micromorph`
// https://github.com/natemoo-re/micromorph // https://github.com/natemoo-re/micromorph
@ -108,7 +109,9 @@ async function _navigate(url: URL, isBack: boolean = false) {
if (!isBack) { if (!isBack) {
if (url.hash) { if (url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView() if (el) {
scrollToElementWithBuffer(el)
}
} else { } else {
window.scrollTo({ top: 0 }) window.scrollTo({ top: 0 })
} }
@ -155,7 +158,9 @@ function createRouter() {
if (isSamePage(url) && url.hash) { if (isSamePage(url) && url.hash) {
const el = document.getElementById(decodeURIComponent(url.hash.substring(1))) const el = document.getElementById(decodeURIComponent(url.hash.substring(1)))
el?.scrollIntoView() if (el) {
scrollToElementWithBuffer(el)
}
history.pushState({}, "", url) history.pushState({}, "", url)
return return
} }

View File

@ -44,3 +44,83 @@ export async function fetchCanonical(url: URL): Promise<Response> {
const [_, redirect] = text.match(canonicalRegex) ?? [] const [_, redirect] = text.match(canonicalRegex) ?? []
return redirect ? fetch(`${new URL(redirect, url)}`) : res return redirect ? fetch(`${new URL(redirect, url)}`) : res
} }
/**
* Highlights an element with a temporary background color effect
* @param el - The element to highlight
* @param duration - How long to show the highlight in milliseconds (default: 2000)
* @param color - The highlight color (default: uses CSS variable --highlight)
*/
export function highlightElement(el: HTMLElement, duration: number = 2000, color: string = 'var(--highlight, #ffeb3b40)') {
// Store original styles
const originalBackground = el.style.backgroundColor
const originalTransition = el.style.transition
// Apply highlight styles
el.style.transition = 'background-color 0.3s ease'
el.style.backgroundColor = color
// Remove highlight after specified duration
setTimeout(() => {
el.style.backgroundColor = originalBackground
// Remove transition after background fades back
setTimeout(() => {
el.style.transition = originalTransition
}, 300)
}, duration)
}
/**
* Scrolls to an element with buffer space from the page header
* @param el - The element to scroll to
* @param buffer - Additional buffer space in pixels (default: 20)
* @param highlight - Whether to highlight the element after scrolling (default: true)
*/
export function scrollToElementWithBuffer(el: HTMLElement, buffer: number = 20, highlight: boolean = true) {
// Get the height of the header to calculate buffer
const header = document.querySelector('.page-header') as HTMLElement
const headerHeight = header ? header.offsetHeight : 0
const totalOffset = headerHeight + buffer
// Calculate the target position
const elementTop = el.offsetTop
const targetPosition = elementTop - totalOffset
// Scroll to the calculated position
window.scrollTo({
top: Math.max(0, targetPosition),
behavior: 'smooth'
})
// Add highlight effect if requested
if (highlight) {
highlightElement(el)
}
}
/**
* Scrolls within a container element to a target element with buffer
* @param container - The container element to scroll within
* @param target - The target element to scroll to
* @param buffer - Buffer space in pixels (default: 20)
* @param highlight - Whether to highlight the element after scrolling (default: true)
* @param behavior - Scroll behavior (default: 'instant')
*/
export function scrollInContainerToElement(
container: HTMLElement,
target: HTMLElement,
buffer: number = 20,
highlight: boolean = true,
behavior: ScrollBehavior = 'instant'
) {
const targetPosition = target.offsetTop - buffer
container.scroll({
top: Math.max(0, targetPosition),
behavior
})
// Add highlight effect if requested
if (highlight) {
highlightElement(target)
}
}