Hold cookie banner scroll lock against smooth scrollers
The preference panel scroll lock relied on `overflow: hidden` on the document element, which JS-driven smooth-scroll libraries such as Lenis (used by Framer, e.g. getbluejay.ai) bypass entirely since they intercept wheel/touch events and scroll programmatically. Pin the body with `position: fixed` (saving and restoring the scroll position) so the viewport has no scroll distance left to animate, even for libraries that capture wheel events on the window before us. Keep capture-phase wheel/touch cancellation as defense-in-depth for sites that scroll an inner container, while still letting the panel's own list scroll. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
@@ -4,6 +4,10 @@ All notable changes to the `@probo/cookie-banner` SDK will be documented in this
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hold the page scroll lock on sites using JS-driven smooth-scroll libraries (e.g. Lenis, as used by Framer) that bypass `overflow: hidden`. The body is now pinned with `position: fixed` (scroll position saved and restored) to remove the viewport's scroll distance entirely, with capture-phase wheel/touch cancellation as defense-in-depth for inner scroll containers; the preference panel's own list can still scroll
|
||||
|
||||
## [0.9.2] - 2026-06-11
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -22,8 +22,15 @@ import { THEMED_STYLES } from "./styles";
|
||||
export class ProboThemedBanner extends HTMLElement {
|
||||
private shadow: ShadowRoot;
|
||||
private scrollLocked = false;
|
||||
private prevOverflow = "";
|
||||
private prevPaddingRight = "";
|
||||
private savedScrollY = 0;
|
||||
private prevHtmlOverflow = "";
|
||||
private prevBodyPosition = "";
|
||||
private prevBodyTop = "";
|
||||
private prevBodyLeft = "";
|
||||
private prevBodyRight = "";
|
||||
private prevBodyWidth = "";
|
||||
private prevBodyOverflow = "";
|
||||
private prevBodyPaddingRight = "";
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -191,24 +198,76 @@ export class ProboThemedBanner extends HTMLElement {
|
||||
private setScrollLock(locked: boolean): void {
|
||||
if (locked === this.scrollLocked) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const html = document.documentElement;
|
||||
const body = document.body;
|
||||
|
||||
if (locked) {
|
||||
const scrollbarWidth = window.innerWidth - root.clientWidth;
|
||||
this.prevOverflow = root.style.overflow;
|
||||
this.prevPaddingRight = root.style.paddingRight;
|
||||
root.style.overflow = "hidden";
|
||||
this.savedScrollY = window.scrollY;
|
||||
const scrollbarWidth = window.innerWidth - html.clientWidth;
|
||||
|
||||
this.prevHtmlOverflow = html.style.overflow;
|
||||
this.prevBodyPosition = body.style.position;
|
||||
this.prevBodyTop = body.style.top;
|
||||
this.prevBodyLeft = body.style.left;
|
||||
this.prevBodyRight = body.style.right;
|
||||
this.prevBodyWidth = body.style.width;
|
||||
this.prevBodyOverflow = body.style.overflow;
|
||||
this.prevBodyPaddingRight = body.style.paddingRight;
|
||||
|
||||
// Pin the body in place. Unlike `overflow: hidden`, this removes the
|
||||
// viewport's scroll distance entirely, so JS-driven smooth-scroll
|
||||
// libraries (Lenis, Locomotive, ...) have nothing left to animate even
|
||||
// when they intercept wheel/touch in the capture phase before us.
|
||||
html.style.overflow = "hidden";
|
||||
body.style.position = "fixed";
|
||||
body.style.top = `-${this.savedScrollY}px`;
|
||||
body.style.left = "0";
|
||||
body.style.right = "0";
|
||||
body.style.width = "100%";
|
||||
body.style.overflow = "hidden";
|
||||
if (scrollbarWidth > 0) {
|
||||
root.style.paddingRight = `${scrollbarWidth}px`;
|
||||
body.style.paddingRight = `${scrollbarWidth}px`;
|
||||
}
|
||||
|
||||
// Defense in depth for the common case where the body is not the
|
||||
// scroller (e.g. an inner scroll container): capture-phase listeners run
|
||||
// before bubble-phase smooth-scroll handlers, cancelling page scroll
|
||||
// while still letting the panel's own scrollable region scroll.
|
||||
window.addEventListener("wheel", this.onScrollLockEvent, { capture: true, passive: false });
|
||||
window.addEventListener("touchmove", this.onScrollLockEvent, { capture: true, passive: false });
|
||||
} else {
|
||||
root.style.overflow = this.prevOverflow;
|
||||
root.style.paddingRight = this.prevPaddingRight;
|
||||
html.style.overflow = this.prevHtmlOverflow;
|
||||
body.style.position = this.prevBodyPosition;
|
||||
body.style.top = this.prevBodyTop;
|
||||
body.style.left = this.prevBodyLeft;
|
||||
body.style.right = this.prevBodyRight;
|
||||
body.style.width = this.prevBodyWidth;
|
||||
body.style.overflow = this.prevBodyOverflow;
|
||||
body.style.paddingRight = this.prevBodyPaddingRight;
|
||||
|
||||
window.removeEventListener("wheel", this.onScrollLockEvent, { capture: true });
|
||||
window.removeEventListener("touchmove", this.onScrollLockEvent, { capture: true });
|
||||
|
||||
// Restore the scroll position the body pinning discarded.
|
||||
window.scrollTo(0, this.savedScrollY);
|
||||
}
|
||||
|
||||
this.scrollLocked = locked;
|
||||
}
|
||||
|
||||
private onScrollLockEvent = (e: Event): void => {
|
||||
// When the gesture happens inside the banner, only stop propagation so a
|
||||
// smooth-scroll library does not move the page; the browser still performs
|
||||
// the panel's native scroll (bounded by its overscroll-behavior: contain).
|
||||
// Anywhere else, cancel the gesture entirely.
|
||||
if (e.composedPath().includes(this)) {
|
||||
e.stopImmediatePropagation();
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
||||
private applyTexts(config: BannerConfig): void {
|
||||
const texts = config.texts ?? {};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user