Skip to content

Instantly share code, notes, and snippets.

@isocroft
Last active March 11, 2025 01:59
Show Gist options
  • Select an option

  • Save isocroft/974e224cf2faf13c61ff3e1f489db1ac to your computer and use it in GitHub Desktop.

Select an option

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.
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;
}
});
}
}
@isocroft
Copy link
Copy Markdown
Author

Remember: This polyfill only works with the full property (i.e. scroll-margin-top) not the short-hand (i.e. scroll-margin).

Simply call polyfillScrollMarginTop()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment