/** * Inspired by https://codepen.io/oxleberry/pen/BOEBaB * * With added support for horizontal scrolling, scrolling the window, and linear easing * * native scrollTo or scrollIntoView conflict with each other if there are different horizontal and vertical scrolls happening */ // Easing equations, http://www.gizma.com/easing/ function easeOutCubic(t: number, b: number, c: number, d: number) { t /= d; t--; return c * (t * t * t + 1) + b; } // function linear(t: number, b: number, c: number, d: number) { // return (c * t) / d + b; // } type SmoothScrollToOptions = { targetPos: number; horizontal?: boolean; duration?: number; getOffsetParent: () => Element | null; }; export function smoothScrollToPosition({ targetPos, horizontal = false, duration = 250, getOffsetParent, }: SmoothScrollToOptions) { let offsetParent = getOffsetParent(); if (!offsetParent) return; if (offsetParent.tagName === 'BODY') { offsetParent = document.getElementsByTagName('html')[0]; if (!offsetParent) return; } // tracks the current X position in pixels const currentPos = horizontal ? offsetParent.scrollLeft : offsetParent.scrollTop; // tracks the remaining distance from target in pixels const distance = targetPos - currentPos; // track the time, for use with request animation let start: null | number = null; let animationId: number | undefined = undefined; const animation = (timestamp: number) => { // timestamp part of reqAF to keep track of animation time if (!start) start = timestamp; // tracks the time elapsed const timeElapsed = timestamp - start; // run, calculates how to reach targetPos in an ease trajectory // 1) value of current time in the animation // 2) currentPos position in pixel // 3) how far we need to go till we reach targetPos in pixels // 4) target end time of animation const run = easeOutCubic(timeElapsed, currentPos, distance, duration); // scrollTo, first argument scrolls on the x axis // scrollTo, second argument scrolls on the y axis // animates till we reach the duration time offsetParent?.scrollTo(horizontal ? { left: run } : { top: run }); if (timeElapsed < duration) { requestAnimationFrame(animation); } else if (animationId) { cancelAnimationFrame(animationId); } }; // recursively renders the animation function animationId = requestAnimationFrame(animation); } export type SmoothScrollOptions = { horizontal?: boolean; duration?: number; getTargetPos?: (target: HTMLElement, offsetParent: Element) => number; getOffsetParent?: (target: HTMLElement) => Element | null; }; export function smoothScrollToTarget( target: HTMLElement, { horizontal = false, duration = 250, getTargetPos, getOffsetParent, }: SmoothScrollOptions = {}, ) { let offsetParent = getOffsetParent ? getOffsetParent(target) : target.offsetParent; if (!offsetParent) return; if (offsetParent.tagName === 'BODY') { offsetParent = document.getElementsByTagName('html')[0]; if (!offsetParent) return; } // tracks the target X positiom in pixels const targetPos = getTargetPos ? getTargetPos(target, offsetParent) : horizontal ? target.offsetLeft : target.offsetTop; return smoothScrollToPosition({ targetPos, horizontal, duration, getOffsetParent: () => offsetParent, }); }