diff --git a/packages/cookie-banner/CHANGELOG.md b/packages/cookie-banner/CHANGELOG.md index b62492d07..86fdee540 100644 --- a/packages/cookie-banner/CHANGELOG.md +++ b/packages/cookie-banner/CHANGELOG.md @@ -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 diff --git a/packages/cookie-banner/src/themed-banner/themed-banner.ts b/packages/cookie-banner/src/themed-banner/themed-banner.ts index 3d44c18b4..3bec1e77c 100644 --- a/packages/cookie-banner/src/themed-banner/themed-banner.ts +++ b/packages/cookie-banner/src/themed-banner/themed-banner.ts @@ -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 ?? {};