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
|
## 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
|
## [0.9.2] - 2026-06-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -22,8 +22,15 @@ import { THEMED_STYLES } from "./styles";
|
|||||||
export class ProboThemedBanner extends HTMLElement {
|
export class ProboThemedBanner extends HTMLElement {
|
||||||
private shadow: ShadowRoot;
|
private shadow: ShadowRoot;
|
||||||
private scrollLocked = false;
|
private scrollLocked = false;
|
||||||
private prevOverflow = "";
|
private savedScrollY = 0;
|
||||||
private prevPaddingRight = "";
|
private prevHtmlOverflow = "";
|
||||||
|
private prevBodyPosition = "";
|
||||||
|
private prevBodyTop = "";
|
||||||
|
private prevBodyLeft = "";
|
||||||
|
private prevBodyRight = "";
|
||||||
|
private prevBodyWidth = "";
|
||||||
|
private prevBodyOverflow = "";
|
||||||
|
private prevBodyPaddingRight = "";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -191,24 +198,76 @@ export class ProboThemedBanner extends HTMLElement {
|
|||||||
private setScrollLock(locked: boolean): void {
|
private setScrollLock(locked: boolean): void {
|
||||||
if (locked === this.scrollLocked) return;
|
if (locked === this.scrollLocked) return;
|
||||||
|
|
||||||
const root = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
const body = document.body;
|
||||||
|
|
||||||
if (locked) {
|
if (locked) {
|
||||||
const scrollbarWidth = window.innerWidth - root.clientWidth;
|
this.savedScrollY = window.scrollY;
|
||||||
this.prevOverflow = root.style.overflow;
|
const scrollbarWidth = window.innerWidth - html.clientWidth;
|
||||||
this.prevPaddingRight = root.style.paddingRight;
|
|
||||||
root.style.overflow = "hidden";
|
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) {
|
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 {
|
} else {
|
||||||
root.style.overflow = this.prevOverflow;
|
html.style.overflow = this.prevHtmlOverflow;
|
||||||
root.style.paddingRight = this.prevPaddingRight;
|
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;
|
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 {
|
private applyTexts(config: BannerConfig): void {
|
||||||
const texts = config.texts ?? {};
|
const texts = config.texts ?? {};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user