From 3e82f42d3c765b5e9becf06fc21e500707268dfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Mon, 3 Aug 2026 18:51:33 +0200 Subject: [PATCH 1/3] feat(core): detect middle-click autoscroll and yield wheel events (#528) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track the browser's native middle-click autoscroll with a small phase state machine (middle pointerdown starts it, pointerup latches sticky mode, the next click's pointerup exits) and ignore wheel events while it is active so lenis doesn't fight the native scrolling. Detection only attaches on Chromium on Windows — the only place native autoscroll ships enabled — and skips middle-clicks on links, which open a tab instead of autoscrolling. Co-Authored-By: Claude Fable 5 --- packages/core/src/auto-scroll-detection.ts | 91 ++++++++++++++++++++++ packages/core/src/lenis.ts | 25 +++--- 2 files changed, 103 insertions(+), 13 deletions(-) create mode 100644 packages/core/src/auto-scroll-detection.ts diff --git a/packages/core/src/auto-scroll-detection.ts b/packages/core/src/auto-scroll-detection.ts new file mode 100644 index 00000000..05eb75c2 --- /dev/null +++ b/packages/core/src/auto-scroll-detection.ts @@ -0,0 +1,91 @@ +// phase 0: initial state +// phase 1: middle pointerdown // autoscroll started +// phase 2: pointerup // sticky autoscroll continues +// phase 3: pointerup (any button) // autoscroll stops + +import { Emitter } from './emitter' + +export class AutoScrollDetection { + private _phase = 0 + private _isEnabled = false + private readonly emitter = new Emitter() + + constructor(private readonly wrapper: HTMLElement) { + // native middle-click autoscroll only exists in Blink on Windows. + // "Chrome" matches all Chromium browsers (Edge/Brave/Opera keep the + // token) but not Firefox/Safari + const ua = navigator.userAgent + if (!(/Windows/.test(ua) && /Chrome/.test(ua))) return + + this.wrapper.addEventListener('pointerdown', this.onPointerDown) + this.wrapper.addEventListener('pointerup', this.onPointerUp) + window.addEventListener('blur', this.onBlur) + } + + destroy() { + this.wrapper.removeEventListener('pointerdown', this.onPointerDown) + this.wrapper.removeEventListener('pointerup', this.onPointerUp) + window.removeEventListener('blur', this.onBlur) + this.emitter.destroy() + } + + private onBlur = () => { + this.phase = 0 + } + + private onPointerDown = (event: PointerEvent) => { + // while sticky autoscroll is active, this click is the exit click — + // keep phase 2 so its pointerup resolves to phase 3 + if (this.phase === 2) return + + if (event.button !== 1) return + + // middle-click on a link opens a tab, no autoscroll starts + const isLinkClick = event + .composedPath() + .some((target) => target instanceof HTMLElement && target.tagName === 'A') + if (isLinkClick) return + + this.phase = 1 + } + + private onPointerUp = (event: PointerEvent) => { + if (this.phase === 1 && event.button === 1) { + this.phase = 2 + } else if (this.phase === 2) { + // any button exits sticky autoscroll, not just middle + this.phase = 3 + } + } + + private set phase(value: number) { + if (value === this._phase) return + + this._phase = value + + this.isEnabled = value === 1 || value === 2 + } + + private get phase() { + return this._phase + } + + get isEnabled() { + return this._isEnabled + } + + private set isEnabled(value: boolean) { + if (value === this._isEnabled) return + + this._isEnabled = value + this.emitter.emit('toggle') + } + + on(event: 'toggle', callback: () => void): () => void { + return this.emitter.on(event, callback) + } + + off(event: 'toggle', callback: () => void): void { + this.emitter.off(event, callback) + } +} diff --git a/packages/core/src/lenis.ts b/packages/core/src/lenis.ts index fa1a73c2..0e079c38 100644 --- a/packages/core/src/lenis.ts +++ b/packages/core/src/lenis.ts @@ -1,5 +1,6 @@ import { version } from '../../../package.json' import { Animate } from './animate' +import { AutoScrollDetection } from './auto-scroll-detection' import { Dimensions } from './dimensions' import { Emitter } from './emitter' import { clamp, modulo } from './maths' @@ -101,6 +102,7 @@ export class Lenis { // These are instanciated in the constructor as they need information from the options readonly dimensions: Dimensions // This is not private because it's used in the Snap class private readonly virtualScroll: VirtualScroll + private readonly autoScrollDetection: AutoScrollDetection constructor({ wrapper = window, @@ -215,10 +217,10 @@ export class Lenis { ) } - this.options.wrapper.addEventListener( - 'pointerdown', - this.onPointerDown as EventListener + this.autoScrollDetection = new AutoScrollDetection( + this.options.wrapper as HTMLElement ) + this.autoScrollDetection.on('toggle', () => this.reset()) // Setup virtual scroll instance this.virtualScroll = new VirtualScroll(eventsTarget as HTMLElement, { @@ -249,10 +251,7 @@ export class Lenis { capture: true, }) - this.options.wrapper.removeEventListener( - 'pointerdown', - this.onPointerDown as EventListener - ) + this.autoScrollDetection.destroy() if (this.options.anchors || this.options.stopInertiaOnNavigate) { this.options.wrapper.removeEventListener( @@ -400,12 +399,6 @@ export class Lenis { } } - private onPointerDown = (event: PointerEvent | MouseEvent) => { - if (event.button === 1) { - this.reset() - } - } - // iOS renders text-selection handles at the start and end points of the // selection. A touch starting within a handle-sized radius of either point is // the user grabbing a handle, not scrolling. @@ -453,6 +446,12 @@ export class Lenis { const isTouch = event.type.includes('touch') const isWheel = event.type.includes('wheel') + // wheel events during the browser's middle-click autoscroll would fight + // the native scrolling, let the browser handle them instead (#528). + if (isWheel && this.autoScrollDetection.isEnabled) { + return + } + // If the touch grabbed an iOS text-selection handle, let the OS adjust the // selection instead of scrolling. Latched on touchstart, held until touchend. if (isTouch && this.isIos) { From 3146428cca416d44489d04f55847e8cc436679be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Mon, 3 Aug 2026 18:56:53 +0200 Subject: [PATCH 2/3] v1.3.26-dev. --- playground/core/test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/playground/core/test.ts b/playground/core/test.ts index b300ff10..805612a1 100644 --- a/playground/core/test.ts +++ b/playground/core/test.ts @@ -128,6 +128,9 @@ const renderDebug = (e: Lenis) => { } lenis.on('scroll', renderDebug) +lenis.on('scroll', (lenis) => { + console.log(lenis.isScrolling) +}) window.addEventListener('scroll', () => renderDebug(lenis), { passive: true }) // document.querySelectorAll('a[href*="#"]').forEach((node) => { From 16b330b3f0f6017b6764b899b4e13026b7d0d0bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Mon, 3 Aug 2026 19:00:50 +0200 Subject: [PATCH 3/3] flag autoscroll as disabled on keydown --- packages/core/src/auto-scroll-detection.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/core/src/auto-scroll-detection.ts b/packages/core/src/auto-scroll-detection.ts index 05eb75c2..c8fa06c3 100644 --- a/packages/core/src/auto-scroll-detection.ts +++ b/packages/core/src/auto-scroll-detection.ts @@ -19,17 +19,19 @@ export class AutoScrollDetection { this.wrapper.addEventListener('pointerdown', this.onPointerDown) this.wrapper.addEventListener('pointerup', this.onPointerUp) - window.addEventListener('blur', this.onBlur) + window.addEventListener('blur', this.reset) + window.addEventListener('keydown', this.reset) } destroy() { this.wrapper.removeEventListener('pointerdown', this.onPointerDown) this.wrapper.removeEventListener('pointerup', this.onPointerUp) - window.removeEventListener('blur', this.onBlur) + window.removeEventListener('blur', this.reset) + window.removeEventListener('keydown', this.reset) this.emitter.destroy() } - private onBlur = () => { + private reset = () => { this.phase = 0 }