Last active
March 11, 2025 01:59
-
-
Save isocroft/974e224cf2faf13c61ff3e1f489db1ac to your computer and use it in GitHub Desktop.
A very simple script that polyfills support for CSS property `scroll-margin-top` in older browsers.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| const browserDoesSupportCSS_ScrollMarginTop = window.CSS.supports("scroll-margin-top", "10px"); | |
| const browserDoesSupportCSS_ScrollBehavior = window.CSS.supports("scroll-behavior", "smooth"); | |
| /* @NOTE: Simple helper to report if a DOM elements' bounding rectangle is partially within the viewport */ | |
| function isElementPartiallyOrWhollyWithinViewport_YAxis (element) { | |
| const rect = element.getBoundingClientRect(); | |
| const minimumYFrame = 0; | |
| const minimumXFrame = 0; | |
| const maximumYFrame = (window.innerHeight || document.documentElement.clientHeight); | |
| const maximumXFrame = (window.innerWidth || document.documentElement.clientWidth); | |
| return ( | |
| rect.top >= minimumYFrame && | |
| rect.left >= minimumXFrame && | |
| rect.bottom <= maximumYFrame && | |
| rect.right <= maximumXFrame | |
| ) && !( | |
| rect.bottom <= minimumYFrame || | |
| rect.right <= minimumXFrame || | |
| rect.y > maximumYFrame || | |
| rect.x > maximumXFrame | |
| ); | |
| } | |
| /* @NOTE: Simple helper to add scroll margin offset to a single DOM Node */ | |
| function addScrollMarginOffset (element, lastKnownScrollY, scrollMarginTopValue) { | |
| if (element !== null) { | |
| /* @HINT: (distance from viewport in the Y axis + how much the page has scroll) - the scroll margin top value */ | |
| const elementYPosition = (element.getBoundingClientRect().top + lastKnownScrollY) - scrollMarginTopValue; | |
| if (!browserDoesSupportCSS_ScrollBehavior) { | |
| window.scroll(0, elementYPosition); | |
| } else { | |
| window.scrollTo({ | |
| top: elementYPosition, | |
| behavior: 'smooth' | |
| }); | |
| } | |
| } | |
| } | |
| /* @NOTE: Simple helper function to check when an array or object lietral is empty */ | |
| function isEmpty (objectValue) { | |
| if (!objectValue || typeof objectValue !== "object") { | |
| return true; | |
| } | |
| for (const prop in objectValue) { | |
| if (Object.prototype.hasOwnProperty.call(objectValue, prop)) { | |
| return false; | |
| } | |
| } | |
| return JSON.stringify(objectValue) === JSON.stringify({}); | |
| } | |
| /* @NOTE: Simple helper to add scroll margin offset to multiple DOM Nodes */ | |
| function adjustForTopSideScrollMargin (lastKnownScrollY, listOfScrollMarginTop, listOfTargets) { | |
| listOfTargets.forEach((target, index) => { | |
| addScrollMarginOffset(target, lastKnownScrollY, listOfScrollMarginTop[index]); | |
| }); | |
| } | |
| function polyfillScrollMarginTop () { | |
| /* @HINT: Only execute polyfill script if browser doesn't support `scroll-margin-top` CSS property */ | |
| if (!browserDoesSupportCSS_ScrollMarginTop) { | |
| /* @NOTE: Get all stylesheets attached to the current web page from everywhere (e.g. STYLE tags, LINK tags */ | |
| const stylesheets = Array.from(document.styleSheets); | |
| /* @INFO Maybe using the PostCSS parser output (AST) might be a better alternative for better performance than this 👇🏾 */ | |
| /* @TODO: << undecided >> 😬 */ | |
| /* @HINT: Extract all CSS rulesets in all stylesheets attached to the current web page */ | |
| /* @CHECK: What is a CSS ruleset ? | https://www.geeksforgeeks.org/what-is-css-ruleset/ */ | |
| const allCssRulesets = stylesheets.flatMap((stylesheet) => [...stylesheet.cssRules]); | |
| /* @HINT: Pack all CSS rulesets containing `scroll-margin-top` CSS property into an object literal */ | |
| const allCssRulesetsContaining_scroll_margin_top = allCssRulesets.filter( | |
| (ruleSet) => { | |
| const ruleSetSelectorText = ruleSet.selectorText || ""; | |
| const ruleSetProperties = ruleSet.style ? Array.from(ruleSet.style) : []; | |
| return (ruleSetSelectorText.includes('[id=') | |
| || ruleSetSelectorText.includes('#') | |
| ) && ruleSetProperties.includes('scroll-margin-top') | |
| } | |
| ).reduce((objectLiteral, currentRuleset) => { | |
| const cssSelector = currentRuleset.selectorText; | |
| const cssDefinition = currentRuleset.cssText; | |
| objectLiteral[cssSelector] = cssDefinition.replace(cssSelector, '').trim(); | |
| return objectLiteral; | |
| }, {}); | |
| /* @NOTE: The `scroll-margin-top` CSS property can have a unit of `rem` so this function helps convert it to a `px` value */ | |
| const convertScrollMarginTopInPixels = (value, baseFontSize = '16px', unit = 'px') => { | |
| if (unit === 'rem') { | |
| return parseInt(baseFontSize) * parseInt(value); | |
| } | |
| return parseInt(value); | |
| }; | |
| if (isEmpty(allCssRulesetsContaining_scroll_margin_top)) { | |
| /* @HINT: Nothing more to do here cos there are no styles to polyfill */ | |
| return; | |
| } | |
| /* Get all selectors for the CSS rulesets that contains `scroll-margin-top` into an array */ | |
| const allRuleSetsCssSelectors = Object.keys(allCssRulesetsContaining_scroll_margin_top); | |
| /* @NOTE: We need to get the base font-size browser setting if the `_unit` is `rem` and not `px` to calculate in pixels */ | |
| const htmlTagStyle = window.getComputedStyle(document.documentElement); | |
| /* @HINT: Filter out all anchor tags that don't relate to our CSS rulsets with a CSS definition for `scroll-margin-top` */ | |
| Array.from(document.querySelectorAll('a[href^="#"]')).filter((anchor) => { | |
| const fragmentTarget = anchor.getAttribute('href'); | |
| return allRuleSetsCssSelectors.join(', ').includes(fragmentTarget); | |
| }).forEach(anchor => { | |
| /* @HINT: Set a `click` event handler on each anchor that passed the filter */ | |
| anchor.addEventListener('click', function(e) { | |
| e.preventDefault(); | |
| const defaultStickyHeader_BannerOffsetInPixels = 10; | |
| const fragmentTarget = e.currentTarget.getAttribute('href'); | |
| /* @HINT: Get the target element which the `scroll-margin-top` CSS property applies to */ | |
| const targetElement = document.querySelector(e.currentTarget.getAttribute('href')); | |
| /* @HINT: Locate CSS selector based on the element this anchor links to on the web page */ | |
| const currentSelectorText = allRuleSetsCssSelectors.find((cssSelector) => { | |
| return cssSelector.includes(fragmentTarget); | |
| }) || "_"; | |
| /* @HINT: Extract the value set for the `scroll-margin-top` CSS property for each CSS selector + CSS ruleset */ | |
| const [, offsetValue] = currentSelectorText === "_" && targetElement !== null | |
| ? [ | |
| null, | |
| window.getComputedStyle(targetElement)['scroll-margin-top'] | |
| || `${defaultStickyHeader_BannerOffsetInPixels}px` | |
| ] | |
| : allCssRulesetsContaining_scroll_margin_top[currentSelectorText].match( | |
| /scroll-margin-top\s*\:\s*([^\s]+)\s*\;/gm | |
| ) || [null, `${defaultStickyHeader_BannerOffsetInPixels}px`]; | |
| /* @HINT: Deal with possible non-numeric values for the `scroll-margin-top` CSS property */ | |
| const normalizedOffsetValue = offsetValue === 'unset' || offsetValue === 'initial' ? '0px' : offsetValue; | |
| /* @HINT: Detect the unit of the `scroll-margin-top` css rule definition */ | |
| const [, _unit] = normalizedOffsetValue.split(/\d+/); | |
| /* @HINT: Extract the value to be used to execute `scroll-margin-top` */ | |
| const scrollMarginTopValue = convertScrollMarginTopInPixels( | |
| normalizedOffsetValue, | |
| htmlTagStyle['font-size'], | |
| _unit === '' ? 'px' : _unit | |
| ); | |
| /* @HINT:Add the `scroll-margin-top` offset on the `targetElement` */ | |
| addScrollMarginOffset(targetElement, window.scrollY, scrollMarginTopValue); | |
| }); | |
| }); | |
| /* @HINT: We need to deal with other occssions where `scroll-margin-top` can be used */ | |
| const allOtherCssRulesetsContaining_scroll_margin_top = allCssRulesets.filter( | |
| (ruleSet) => { | |
| const ruleSetProperties = Array.from(ruleSet.style); | |
| return !(ruleSet.selectorText.includes('[id=') | |
| || ruleSet.selectorText.includes('#') | |
| ) && ruleSetProperties.includes('scroll-margin-top') | |
| }, | |
| ).reduce((objectLiteral, currentRuleset) => { | |
| const cssSelector = currentRuleset.selectorText; | |
| const cssDefinition = currentRuleset.cssText; | |
| objectLiteral[cssSelector] = cssDefinition.replace(cssSelector, '').trim(); | |
| return objectLiteral; | |
| }, {}); | |
| if (isEmpty(allOtherCssRulesetsContaining_scroll_margin_top)) { | |
| /* @HINT: Nothing more to do here cos there are no styles to polyfill */ | |
| return; | |
| } | |
| /* Get all other targets that relate to the `scroll-margin-top` CSS property into an array */ | |
| const allOtherScrollMarginTopTargets = Object.keys( | |
| allOtherCssRulesetsContaining_scroll_margin_top | |
| ).flatMap((selectorText) => { | |
| return Array.from(document.querySelectorAll(selectorText)); | |
| }); | |
| let lastKnownScrollYPosition = window.scrollY; | |
| let ticking = false; | |
| let requestId = null; | |
| document.addEventListener("scroll", function(e) { | |
| lastKnownScrollYPosition = window.scrollY; | |
| /* @HINT: Execute only when the bubbling phase for `scroll` event delegation is over */ | |
| if (!ticking) { | |
| const applyScrollMarginOnTopSide = () => { | |
| executing = true; | |
| const filteredTargets = allOtherScrollMarginTopTargets.filter( | |
| (target) => { | |
| return isElementPartiallyOrWhollyWithinViewport_YAxis(target) | |
| && target.getBoundingClientRect().top <= 0 | |
| } | |
| ) | |
| adjustForTopSideScrollMargin( | |
| lastKnownScrollYPosition, | |
| filteredTargets.map( | |
| (target) => { | |
| const style = window.getComputedStyle(target); | |
| return style['scroll-margin-top'] || '0px'; | |
| }).map((offsetValue) => { | |
| /* @HINT: Deal with possible non-numeric values for the `scroll-margin-top` CSS property */ | |
| const normalizedOffsetValue = offsetValue === 'unset' || offsetValue === 'initial' ? '0px' : offsetValue; | |
| /* @HINT: Detect the unit of the `scroll-margin-top` css rule definition */ | |
| const [, _unit] = normalizedOffsetValue.split(/\d+/); | |
| /* @HINT: Extract the value to be used to execute `scroll-margin-top` */ | |
| return convertScrollMarginTopInPixels( | |
| normalizedOffsetValue, | |
| htmlTagStyle['font-size'], | |
| _unit === '' ? 'px' : _unit | |
| ) | |
| }), | |
| filteredTargets | |
| ); | |
| ticking = false; | |
| requestId = null; | |
| }; | |
| if (requestId !== null) { | |
| typeof window.requestAnimationFrame === "function" | |
| ? window.cancelAnimationFrame(requestId) | |
| : window.clearTimeout(requestId); | |
| } | |
| requestId = typeof window.requestAnimationFrame === "function" | |
| ? window.requestAnimationFrame(applyScrollMarginOnTopSide) | |
| : window.setTimeout(applyScrollMarginOnTopSide, 0); | |
| ticking = true; | |
| } | |
| }); | |
| } | |
| } |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Simply call polyfillScrollMarginTop()