ScrollPadlock
A small (~4K gzipped) unobtrusive script aimed to encourage a CSS-first approach when locking html elements scroll reducing cumulative layout shift and iOS Safari quirkiness.
🙅 Without this library:
💁 With this library:
Try it out
Here's some example projects for the most common setups:
Inclusion
This library is downloadable via npm:
$ npm install scroll-padlock
The source code is entirely written in standard ECMAScript with no dependencies.
All major bundle formats are supported, including umd, iife, amd, cjs, esm and SystemJS; a minified gzipped version is also available for each bundle format.
Node:
import ScrollPadlock from "scroll-padlock";
const scrollPadlock = new ScrollPadlock();
Browser (modules):
<script type="module">
import ScrollPadlock from "path/to/scroll-padlock/dist/es/scroll-padlock.min.js";
const scrollPadlock = new ScrollPadlock();
</script>
Browser (globals):
<script src="path/to/scroll-padlock/dist/iife/scroll-padlock.min.js"></script>
<script>
var scrollPadlock = new ScrollPadlock();
</script>
Under the Hood
Some CSS variables, addressing a given html element data attribute (dinamically set), are set through a style
appended in head
, a given CSS class is observed to determine current state while window resize and scroll events are listened in order to update CSS variables; no other DOM modifications besides that.
Usage (basic)
By default, a padlock instance addresses the default browser scrolling element and a scroll-padlock-locked
css class.
.scroll-padlock-locked {
overflow: hidden;
padding-right: var(--scroll-padlock-scrollbar-width);
}
const instance = new ScrollPadlock();
At this point, the lock state can be changed simply toggling that CSS class; since the CSS class change is internally observed, the class change itself can be done through native DOM API, a virtual DOM library, another DOM manipulation script, etc...
document.scrollingElement.classList.add('scroll-padlock-locked');
document.scrollingElement.classList.remove('scroll-padlock-locked');
Usage (advanced)
A custom scrolling element and a custom css class name are both supported.
const customScrollingElement = document.querySelector('#custom-scrolling-element');
const instance = new ScrollPadlock(
customScrollingElement,
"custom-scrolling-element-scroll-locked",
window,
);
customScrollingElement.classList.add("custom-scrolling-element-scroll-locked");
customScrollingElement.classList.remove("custom-scrolling-element-scroll-locked");
The first constructor argument can be a single object of options.
const instance = new ScrollPadlock({
scrollingElement: document.scrollingElement,
scrollEventElement: window,
cssClassName: 'locked-state-css-class',
resizeHandlerWrapper: handler => handler(),
scrollHandlerWrapper: handler => handler(),
client: window,
});
The following ruleset alone is enough to ensure a cross-browser page scroll lock for a standard vertical-scroll page:
.scroll-padlock-locked {
overflow: hidden;
padding-right: var(--scroll-padlock-scrollbar-width);
position: fixed;
width: 100%;
top: calc(var(--scroll-padlock-scroll-top) * -1);
}
CSS Variables
This is the complete list of CSS variables set by this library on the given elements.
--scroll-padlock-scroll-top
: the number of pixels that the scrolling element is scrolled vertically.--scroll-padlock-scroll-left
: the number of pixels that the scrolling element is scrolled horizontally.--scroll-padlock-scrollbar-width
: the scrolling element's vertical scrollbar size.--scroll-padlock-scrollbar-height
: the scrolling element's horizontal scrollbar size.--scroll-padlock-outer-width
: the scrolling element's width including the scrollbar size.--scroll-padlock-outer-height
: the scrolling element's height including the scrollbar size.--scroll-padlock-inner-width
: the scrolling element's width without the scrollbar size.--scroll-padlock-inner-height
: the scrolling element's height without the scrollbar size.--scroll-padlock-scroll-width
: the scrolling element's content width.--scroll-padlock-scroll-height
: the scrolling element's content height.
API
The destroy
method is particularly important when using reactive frameworks (such as React, Vue, Angular, etc...) which components lifecycle might generate memory leaks: call destroy
method when the component in which scroll-padlock is used gets unmounted.
instance.destroy();
Some other methods or accessors can be useful when custom DOM-manipulation logic takes place.
instance.update();
const { top, left } = instance.scroll;
instance.scroll = { top, left };
const {
outerWidth,
outerHeight,
innerWidth,
innerHeight,
scrollWidth,
scrollHeight,
scrollbarWidth,
scrollbarHeight,
} = instance.layout;
const {
cssClassName,
scrollingElement,
scrollEventElement,
} = instance;
instance.unlisten('resize');
instance.unlisten('scroll');
instance.listen('resize');
instance.listen('scroll');
TL;TR: a page scroll lock overview
🙅 overflow: hidden
is the most common way to lock the scroll position on every browsers, unfortunately, unless user's browser has overlay scrollbars, that would cause the scrollbar to disappear, the body to expand and the contents to jump to the right (CLS);
to make things worse that technique just doesn't work on iOS safari: when set the user can still somehow scroll the page.
🙅 touch-action: none
can't help since Safari doesn't seem to support it anytime soon.
🤷 Some libraries propose to solve this preventing touchmove
events, which might work out very well in many cases; unfortunately some issues with some viewport
configurations or pinch to zoom might still be encountered, also iOS navigation bars might end up covering some layout elements.
🙅 position: fixed
alone can force iOS to lock the scroll, but when applied the scroll position would eventually jump to the top of the page.
💁 This library sets some css variables and css classes in order to allow the developer to choose their preferred CSS-only approach, while the class instance exposes a quite granular API in order to implement some JS strategies too.
Positioned Elements
If positioned elements "jumps" on a parent lock state change, the same CSS variables that are used to reserve the scrollbar width can be used to overcome this problem as well.
.positioned-element {
position: fixed;
right: 0;
}
.scroll-padlock-locked .positioned-element {
right: var(--scroll-padlock-scrollbar-width);
}
iOS Bars and Keyboard Tray
There might still be an iOS edge case when locking page scroll with position: fixed technique.
When the page is scrolled the system bars become smaller; at that point, when focusing an input element programmatically, the keyboard tray is triggered and the bars become larger again; that, probably when some animations are taking place, can cause the following visual artifacts.
iOS forces a scroll to the focused element (still out of canvas) in an already "locked" area (limited by the OS itself) which would be also shortly resized because of the system bars getting bigger.
To overcome this problem the native resize
event can be listened to programmatically scroll to top that ios-keyboard-sub-window-thing.
window.addEventListener("resize", () => {
if (document.scrollingElement.contains("scroll-padlock-locked")) {
window.scrollTo(0, 0);
}
});
The problem should be solved at this point.
Support
All modern browsers have been tested.
The library doesn't provide a fallback for those browsers which don't support CSS variables (mainly Internet Explorer 11); since these browsers tipically support overflow: hidden, the JS API can be used to implement the scrollbars-gaps compensation normally achievable through CSS by standard browsers (a graceful degradation approach is highly suggested though).
Development
Node version 20.2.0 or higher is required in order to compile source code or launch tests.