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:
Émile Ré
2026-06-11 18:35:46 +02:00
parent 01cd58cba4
commit 92e829cbe5
2 changed files with 73 additions and 10 deletions

View File

@@ -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

View File

@@ -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 ?? {};