From 1ad3e3763da88b7a4634e1fa754edeb109872372 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Wed, 13 May 2026 15:06:05 +0200 Subject: [PATCH 01/25] phase1 --- .claude/settings.local.json | 8 +- MULTI-AXIS-PLAN.md | 113 +++++++ V2-ROADMAP.md | 9 +- packages/core/src/axis.ts | 165 +++++----- packages/core/src/lenis.ts | 559 ++++++++++++++++++++++------------ packages/core/src/types.ts | 11 +- playground/two-axis/style.css | 16 +- playground/two-axis/test.ts | 24 +- 8 files changed, 615 insertions(+), 290 deletions(-) create mode 100644 MULTI-AXIS-PLAN.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 644f9de2..fc84ae30 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,13 @@ "Bash(mv playground/www/pages/side-by-side.astro playground/www/pages/touch.astro)", "Bash(Select-String -Pattern \"lock|isLocked\" -CaseSensitive)", "Bash(Select-Object -First 20)", - "Read(//c/Users/clement/AppData/Local/Temp/claude/C--Users-clement-Desktop-darkroom-lenis/6e2f0498-3445-4568-b59c-65b9efda1d7c/tasks/**)" + "Read(//c/Users/clement/AppData/Local/Temp/claude/C--Users-clement-Desktop-darkroom-lenis/6e2f0498-3445-4568-b59c-65b9efda1d7c/tasks/**)", + "Bash(npx biome *)", + "Bash(npx @biomejs/biome check packages/core/src/axis.ts packages/core/src/lenis.ts)", + "Bash(npx tsdown *)", + "Bash(npx @biomejs/biome check packages/core/src)", + "Bash(npx @biomejs/biome check --write packages/core/src/lenis.ts packages/core/src/axis.ts)", + "Bash(npx @biomejs/biome check --write packages/core/src)" ] } } diff --git a/MULTI-AXIS-PLAN.md b/MULTI-AXIS-PLAN.md new file mode 100644 index 00000000..8248ddcc --- /dev/null +++ b/MULTI-AXIS-PLAN.md @@ -0,0 +1,113 @@ +# Multi-axis scrolling — implementation plan + +> Companion to [`V2-ROADMAP.md`](./V2-ROADMAP.md) → "Multi-axis scrolling". This file is the working plan; the roadmap just links here. + +## Goal & guiding principle + +Allow simultaneous horizontal + vertical scrolling (2D canvas, maps, spreadsheets, layouts that scroll both ways) **without complexifying the API for the 99% who only scroll one axis**. + +- 2D is **opt-in** via a single option value: `new Lenis({ orientation: 'both' })`. +- When `orientation: 'both'`, `gestureOrientation` has no effect — the `y` axis reacts to vertical gestures, the `x` axis to horizontal gestures. +- Single-axis behaviour and API stay **100% unchanged**. Multi-axis is purely additive: you *gain* `lenis.x` / `lenis.y`. +- **All config is global, never per-axis.** `wheel`, `touch`, `lerp`, `duration`, `easing`, `dimensions`, `infinite`, `overscroll` — configured once on `Lenis`, shared by both axes. The `Axis` class does **not** take its own options bag and there is no per-axis override mechanism. (If one is ever genuinely needed it'd be a future `touch.ios`-style escape hatch, not part of this work.) + +## Target API + +```ts +// DEFAULT — single axis, unchanged. Base users never see any of this. +const lenis = new Lenis() +lenis.on('scroll', (lenis) => { lenis.scroll; lenis.progress; lenis.velocity; lenis.direction }) +lenis.scrollTo(500) +lenis.scrollTo('#section', { offset: -100 }) + +// OPT-IN 2D — one new option value. +const lenis = new Lenis({ orientation: 'both' }) + +lenis.x // Axis — reacts to horizontal gestures +lenis.y // Axis — reacts to vertical gestures +// each Axis mirrors the single-axis scroll surface: +lenis.x.scroll // number +lenis.x.targetScroll +lenis.x.animatedScroll +lenis.x.progress // 0..1 +lenis.x.velocity +lenis.x.direction // 1 | -1 | 0 +lenis.x.limit +lenis.x.isScrollable +lenis.x.scrollTo(200, { duration: 1 }) + +// scroll event — callback still receives `lenis`; destructure if you like +lenis.on('scroll', ({ x, y }) => { // x, y are Axis instances + el.style.transform = `translate(${-x.scroll}px, ${-y.scroll}px)` +}) + +// scrollTo in 2D +lenis.scrollTo({ x: 200, y: 800 }, { duration: 1.2 }) // both axes, animated together +lenis.scrollTo('#section') // resolves element rect → both axes +lenis.scrollTo({ y: 800 }) // only y moves +lenis.x.scrollTo(200) // single axis, numbers only + +// raf — unchanged (autoRaf: true by default) +``` + +### Decisions + +- **Event shape:** unchanged. `on('scroll', cb)` still passes the `Lenis` instance; `({ x, y }) => {}` works because `x`/`y` are properties on it. No event redesign, no breaking change. +- **Top-level scalars in `'both'` mode** → **alias the vertical axis** (`lenis.scroll === lenis.y.scroll`, `lenis.scrollTo(500) === lenis.y.scrollTo(500)`, …). Purely additive — a v1 component dropped into a 2D page keeps working. The "real" 2D API is `lenis.x` / `lenis.y` / `lenis.scrollTo({ x, y })`. +- **`Axis` ownership:** per-axis *state + motion* only — `animatedScroll`, `targetScroll`, `velocity`, `lastVelocity`, `direction`, getters `scroll` / `progress` / `limit` / `isScrollable`, methods `scrollTo(number, opts)` / `advance(dt)` / `reset()`, its own `Animate`. **No DOM writes** (Lenis flushes once per frame), **no event listeners**, **no options bag**, **no classnames**. +- **`Lenis` ownership (the controller):** event wiring, `onGesture` / `onClick`, `Dimensions`, `isScrolling` / `isTouch` / `isWheel` / `isLocked`, classnames, `raf`, `emit`, the options bag. Holds `readonly x: Axis` and `readonly y: Axis` — both always exist; the inactive one is just inert (`isScrollable === false`). + +### `Lenis.raf` per frame (2D) + +```ts +this.x.advance(dt) +this.y.advance(dt) +this.options.wrapper.scrollTo({ left: this.x.scroll, top: this.y.scroll, behavior: 'instant' }) +``` + +(Single axis: same, just one axis is inert.) + +## Steps + +Ordered so each is a discrete, independently-shippable unit. `→` marks dependencies. + +### Phase 0 — foundation (no public API change) — ✅ DONE + +1. ✅ **Clean-sheet `Axis` class** (`packages/core/src/axis.ts`) — pure per-axis state (`animatedScroll`, `targetScroll`, `velocity`, `lastVelocity`, `direction`) + own `Animate`; getters `scroll` / `progress` / `limit` / `actualScroll` / `cssOverflow`; methods `setScroll` / `reset` / `advance` / `destroy`. Reads everything it needs lazily off the `Lenis` ref (`options`, `dimensions`, `rootElement`) — no options bag of its own, no listeners, no class names, no DOM-write fan-out beyond its own coordinate. Old scaffold (`console.log`, getter-only `isStopped`/`isLocked` stubs, dangling `start`/`stop`/`emit`) deleted. + - ⏭️ `Axis.scrollTo(target: number, opts)` **not** extracted yet — the `scrollTo` state machine still lives on `Lenis` and operates on the active axis. Moving it into `Axis` needs host callbacks (`emit`, set `isScrolling`, `userData`, `preventNextNativeScrollEvent`, `dispatchScrollendEvent`, the `Lenis` ref for `onStart`/`onComplete`); deferred to **Phase 1 step 7** where per-axis `scrollTo` dispatch actually needs it. +2. ✅ **`Lenis` delegates to one active `Axis`** — holds `readonly x: Axis` / `readonly y: Axis`, `private get activeAxis()` = `isHorizontal ? x : y`. `scroll` / `progress` / `limit` / `actualScroll` getters and `targetScroll` / `animatedScroll` / `velocity` / `lastVelocity` / `direction` get+set pairs all delegate to `activeAxis` (so `scrollTo` / `onGesture` / `onNativeScroll` bodies were untouched). `raf` advances both axes; `destroy` destroys both; `checkOverflow` reads `activeAxis.cssOverflow`. **Zero public API change.** Verified: typecheck clean, biome clean, builds. +3. ✅ **Consolidate DOM writes** — `Axis.advance(dt)` now returns whether the animation was running that frame; `Lenis.raf` flushes via `private flushScroll()` once after advancing both, with a single `wrapper.scrollTo({ left?, top?, behavior: 'instant' })` call that only writes the live axis(es) per `orientation`. `scrollAxisTo`'s `onUpdate` no longer writes DOM. The `immediate` branch still writes synchronously via `axis.setScroll` (it runs outside `raf`). +4. ✅ **Per-axis native-scroll handling** — `onNativeScroll` now updates **both** axes from their respective `actualScroll` (`wrapper.scrollX` for `x`, `wrapper.scrollY` for `y`), with per-axis `velocity`/`lastVelocity`/`direction`. The velocity-reset timeout fires if *any* axis moved. + +> Not yet verified in a real browser — needs a manual pass against `playground/core` (vertical) and `playground/www/pages/horizontal.astro` (horizontal) since there are no automated tests. + +### Phase 1 — `orientation: 'both'` (the feature) — steps 5–7 ✅ + +5. ✅ **Accept `orientation: 'both'`** — added to `Orientation` in `types.ts`. `gestureOrientation` default fixed to `orientation === 'vertical' ? 'vertical' : 'both'` (so `'both'` orientation defaults to `gestureOrientation: 'both'` and has no effect for routing). `lenis.x` / `lenis.y` already public from Phase 0. Top-level scalars (`lenis.scroll`, `scrollTo(number)`, …) alias the vertical axis (because `isHorizontal` returns `false` for `'both'`, so `activeAxis = y`) — fully back-compatible. +6. ✅ **2D gesture routing** — `onGesture` branches after the `isSmooth` check: in `orientation: 'both'`, `deltaX` drives `x` via `scrollAxisTo(x, x.targetScroll + dx)` and `deltaY` drives `y` similarly. Per-axis touch-end inertia (`Math.sign(d) * |axis.velocity| ** inertia`). `data-lenis-prevent-horizontal` / `-vertical` still applies per the dominant gesture direction (existing logic). Overscroll edge-detection simplified to always-stopPropagation in 2D for now (Phase 9 refinement). +7. ✅ **`scrollTo({ x?, y? })` overload** + **`Axis.scrollTo(number, opts)`** — extracted the animation state machine into `Lenis.scrollAxisTo(axis, target, opts)` (`@internal` — operates on the given axis; Lenis-level state stays on `this`). Public `Lenis.scrollTo` is now overloaded: `(target: number | string | HTMLElement, opts?)` resolves on the active axis; `(target: { x?, y? }, opts?)` dispatches to each axis. `Axis.scrollTo(target: number, opts?)` is a thin wrapper around `scrollAxisTo(this, …)` — so `lenis.x.scrollTo(200)` works. Completion logic only clears `isScrolling = false` when **no axis is animating** (`isAnyAxisAnimating` guard) so finishing one axis doesn't kill another's animation. `Lenis.reset()` now resets both axes. +8. ✅ **`scrollTo(element | selector)` in 2D** — extracted `private resolveElementTarget(node, axis, offset)`. In `orientation: 'both'`, `lenis.scrollTo('#section')` (or an `HTMLElement`) resolves the element rect to a target *per axis* (scroll-margin / scroll-padding / wrapper-rect correction each computed on the right side) and dispatches to both axes simultaneously. Covers anchor-link navigation in 2D. Keywords (`'top'`, `'left'`, `'start'`, `'#'`, `'bottom'`, `'right'`, `'end'`) stay single-axis on the active (vertical) axis — for 2D keyword behaviour, pass `{ x: 0, y: 0 }`. + +### Phase 2 — edges & polish + +9. **Nested scroll in 2D** → 6. `isScrollableElement` / `allowNestedScroll` considering both axes. Rule: if a composed-path element can scroll in the gesture's dominant direction, defer the whole gesture (don't split nested + lenis across axes). Document the limitation. +10. **Classnames** — `lenis-scrolling` if *either* axis scrolling; `lenis-stopped` if *neither* scrollable. Decide whether to keep/extend `lenis-horizontal` / `window.lenis.horizontal` in 2D. +11. **Events / direction-velocity semantics** — no event-shape change; confirm `velocity` / `direction` meaning in 2D, document the `({ x, y }) => {}` pattern. + +### Phase 3 — ecosystem & docs + +12. **React / Vue wrappers** — `useLenis` exposing per-axis state; updated types. +13. **`playground/two-axis` polish** — wire it as the real test bed / example. +14. **Docs + migration note** — `orientation: 'both'`, `lenis.x` / `lenis.y`, `scrollTo({ x, y })`, "`gestureOrientation` has no effect when `'both'`". + +**Minimum path to "it works":** 1 → 2 → 4 → 5 → 6 → 7 (with 3 folded into 2; 8–14 as follow-ups). + +## Decided constraints + +- **`infinite` is global** — one boolean, applies to whichever axes are live (not per-axis). +- **`overscroll` is global** — same. +- **`wheel` / `touch` config is global** — no per-axis lerp/duration/easing/multiplier/etc. + +## Open questions + +- **Keep `orientation` long-term, or go fully CSS-driven** — an axis is "live" iff its `overflow-{x|y}` ∉ {hidden,clip} ∧ content overflows that way (mirrors how `isScrollable` already works). Could stay `orientation: 'both'` for v2 and revisit. Note the migration cost for horizontal-scroll sites if `orientation` is ever removed (`orientation: 'horizontal'` → CSS + `lenis.x`). diff --git a/V2-ROADMAP.md b/V2-ROADMAP.md index 70215016..dd33a3e0 100644 --- a/V2-ROADMAP.md +++ b/V2-ROADMAP.md @@ -176,12 +176,11 @@ iOS detection handles the iPadOS 13+ desktop-UA case via `navigator.maxTouchPoin ### 🚧 Multi-axis scrolling -Allows simultaneous horizontal and vertical scrolling for use cases like 2D canvas navigation, maps, spreadsheets, and layouts that scroll in both directions. In progress: +Simultaneous horizontal + vertical scrolling (2D canvas, maps, spreadsheets, layouts that scroll both ways), opt-in via `new Lenis({ orientation: 'both' })`. Single-axis API stays unchanged; you gain `lenis.x` / `lenis.y`. -- 🚧 `Axis` class (`packages/core/src/axis.ts`) — per-axis `animatedScroll` / `targetScroll`, `Animate` instance, `cssOverflow` + `overflow` (content vs. viewport) getters, `scrollTo`, `advance`. Still scaffolding — `isStopped` / `isLocked` getters are stubs, `Lenis` doesn't yet delegate to it, and `console.log`s remain. -- 🚧 `playground/two-axis` — 5×5 viewport grid (`500vw × 500vh`) for eyeballing 2D scroll behavior -- ⏳ Wire `Lenis` to drive two `Axis` instances; expose `lenis.axes` / per-axis state -- ⏳ Decide the public API surface (per-axis `scrollTo`, events, dimensions) +**Full design + step-by-step plan: [`MULTI-AXIS-PLAN.md`](./MULTI-AXIS-PLAN.md).** + +Current state: `Axis` class exists (`packages/core/src/axis.ts`) but is rough scaffolding — getter-only `isStopped` / `isLocked` stubs, references members that don't exist yet, leftover `console.log`; `Lenis` doesn't delegate to it; `playground/two-axis` (5×5 viewport grid) is in place as a test bed. Next: clean-sheet the `Axis` class, then refactor `Lenis` to delegate to it (single-axis, no API change) before adding `orientation: 'both'`. ### ⏳ Auto CSS injection diff --git a/packages/core/src/axis.ts b/packages/core/src/axis.ts index 666cc49b..1b8356d5 100644 --- a/packages/core/src/axis.ts +++ b/packages/core/src/axis.ts @@ -1,110 +1,117 @@ import { Animate } from './animate' import type { Lenis } from './lenis' -import { clamp, modulo } from './maths' - +import { modulo } from './maths' +import type { ScrollToOptions } from './types' + +/** + * A single scroll axis (`x` or `y`). `Lenis` owns one per direction; in single-axis + * mode only the active one is used, with `orientation: 'both'` both are live. + * + * Holds the per-axis scroll state and the animation that drives it. It does not + * touch gestures, events, class names or the options — that stays on `Lenis`. + */ export class Axis { + /** Animated (interpolated) scroll value */ animatedScroll = 0 + /** Target scroll value the animation is moving toward */ targetScroll = 0 - private readonly animate = new Animate() - constructor( - private axis: 'x' | 'y', - private lenis: Lenis - ) { - this.axis = axis - this.lenis = lenis - this.animate = new Animate() - } - - get cssOverflow() { - return !['hidden', 'clip'].includes( - getComputedStyle(this.lenis.rootElement)[ - (this.axis === 'x' - ? 'overflow-x' - : 'overflow-y') as keyof CSSStyleDeclaration - ] as string - ) - } + /** Current scroll velocity (delta since the last update) */ + velocity = 0 + /** Scroll velocity from the previous update */ + lastVelocity = 0 + /** Scroll direction: `1` forward, `-1` backward, `0` idle */ + direction: 1 | -1 | 0 = 0 - get overflow() { - return this.axis === 'x' - ? this.lenis.dimensions.scrollWidth! > this.lenis.dimensions.width! - : this.lenis.dimensions.scrollHeight! > this.lenis.dimensions.height! - } + /** @internal the animation driving this axis */ + readonly animate = new Animate() - get isScrollable() { - return this.cssOverflow && this.overflow + constructor( + /** Which axis this represents */ + readonly axis: 'x' | 'y', + private readonly lenis: Lenis + ) {} + + /** @internal */ + destroy() { + this.animate.stop() } - checkOverflow() { - if (this.cssOverflow) { - this.start() - } else { - this.stop() - } + /** + * Reset all scroll state to the browser's current scroll position and stop the animation. + */ + reset() { + this.animatedScroll = this.targetScroll = this.actualScroll + this.lastVelocity = this.velocity = 0 + this.animate.stop() } - private start() { - if (!this.isStopped) return - - this.reset() - this.isStopped = false - this.emit() + /** + * Advance the animation by `deltaTime` (in seconds). Returns `true` if the + * animation was running this frame (i.e. `animatedScroll` may have changed and + * the DOM needs to reflect it). + */ + advance(deltaTime: number) { + const wasRunning = this.animate.isRunning + this.animate.advance(deltaTime) + return wasRunning } - private stop() { - if (this.isStopped) return - - this.reset() - this.isStopped = true - this.emit() + /** + * Scroll this axis to a numeric target. Thin wrapper around `lenis.scrollAxisTo` + * so this axis is the one driven. + */ + scrollTo(target: number, options?: ScrollToOptions) { + this.lenis.scrollAxisTo(this, target, options) } - get limit() { - return this.lenis.dimensions.limit[this.axis]! + /** Write a scroll value to the wrapper for this axis (bypasses `scroll-behavior`). */ + setScroll(value: number) { + this.lenis.options.wrapper.scrollTo( + this.axis === 'x' + ? { left: value, behavior: 'instant' } + : { top: value, behavior: 'instant' } + ) } - get isStopped() {} + /** + * The scroll value the browser currently reports for this axis. + * + * It has to be read this way because of the DOCTYPE declaration: `window` exposes + * `scrollX`/`scrollY`, scroll-container elements expose `scrollLeft`/`scrollTop`. + */ + get actualScroll() { + const wrapper = this.lenis.options.wrapper as Window | HTMLElement - get isLocked() {} + return this.axis === 'x' + ? ((wrapper as Window).scrollX ?? (wrapper as HTMLElement).scrollLeft) + : ((wrapper as Window).scrollY ?? (wrapper as HTMLElement).scrollTop) + } + /** The current scroll value (wrapped to `limit` when `infinite`). */ get scroll() { return this.lenis.options.infinite ? modulo(this.animatedScroll, this.limit) : this.animatedScroll } - setScroll(scroll: number) { - this.lenis.options.wrapper.scrollTo({ - [this.axis === 'x' ? 'left' : 'top']: scroll, - behavior: 'instant', - }) + /** The maximum scroll value for this axis. */ + get limit() { + return this.lenis.dimensions.limit[this.axis] } - scrollTo( - _target: number, - { - programmatic = true, - lerp = programmatic ? this.lenis.options.wheel.lerp : undefined, - duration = programmatic ? this.lenis.options.duration : undefined, - easing = programmatic ? this.lenis.options.easing : undefined, - } - ) { - const target = clamp(0, _target, this.limit) - - console.log(this.targetScroll, target) - this.targetScroll = target - this.animate.fromTo(this.animatedScroll, target, { - lerp, - duration, - easing, - onUpdate: (value: number) => { - this.animatedScroll = value - this.setScroll(this.scroll) - }, - }) + /** Scroll progress relative to `limit`, `0..1`. */ + get progress() { + // avoid progress being NaN + return this.limit === 0 ? 1 : this.scroll / this.limit } - advance(deltaTime: number) { - this.animate.advance(deltaTime) + /** Whether this axis's CSS `overflow` permits scrolling (not `hidden`/`clip`). */ + get cssOverflow() { + const property = this.axis === 'x' ? 'overflow-x' : 'overflow-y' + const value = getComputedStyle(this.lenis.rootElement)[ + property as keyof CSSStyleDeclaration + ] as string + + return !['hidden', 'clip'].includes(value) } } diff --git a/packages/core/src/lenis.ts b/packages/core/src/lenis.ts index 89185def..f15b731f 100644 --- a/packages/core/src/lenis.ts +++ b/packages/core/src/lenis.ts @@ -1,11 +1,10 @@ import { version } from '../../../package.json' -import { Animate } from './animate' +import { Axis } from './axis' import { Dimensions } from './dimensions' import { Emitter } from './emitter' import { GesturesHandler } from './gestures-handler' -import { clamp, modulo } from './maths' +import { clamp } from './maths' import type { - GestureCallback, GestureData, LenisEvent, LenisOptions, @@ -31,17 +30,17 @@ const defaultEasing = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t)) export class Lenis { private _isScrolling: Scrolling = false // true when scroll is animating private _isScrollable = true // true if element is scrollable (computed from css overflow property) - private _isLocked = false // // true when user-initiated scroll (wheel/touch) is suppressed — toggled via lock()/unlock() + private _isLocked = false // true when user-initiated scroll (wheel/touch) is suppressed — toggled via lock()/unlock() private _preventNextNativeScrollEvent = false private _resetVelocityTimeout: ReturnType | null = null private _rafId: number | null = null /** - * Whether or not the user is touching the screen + * Whether or not the last gesture was a touch */ isTouch?: boolean /** - * Whether the root this user is wheel scrolling + * Whether the last gesture was a wheel */ isWheel?: boolean /** @@ -59,18 +58,6 @@ export class Lenis { * }) */ userData: UserData = {} - /** - * The last velocity of the scroll - */ - lastVelocity = 0 - /** - * The current velocity of the scroll - */ - velocity = 0 - /** - * The direction of the scroll - */ - direction: 1 | -1 | 0 = 0 /** * The options passed to the lenis instance */ @@ -78,20 +65,15 @@ export class Lenis { Required, 'duration' | 'easing' | 'onGesture' | 'content' | 'dimensions' > - /** - * The target scroll value - */ - targetScroll: number - /** - * The animated scroll value - */ - animatedScroll: number - // These are instanciated here as they don't need information from the options - private readonly animate = new Animate() + // Instanciated here as it doesn't need information from the options private readonly emitter = new Emitter() - // 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 + // Instanciated in the constructor as they need information from the options + readonly dimensions: Dimensions // not private — used by the Snap class + /** The horizontal scroll axis */ + readonly x: Axis + /** The vertical scroll axis */ + readonly y: Axis private readonly gesturesHandler: GesturesHandler private readonly isIOS: boolean @@ -102,8 +84,8 @@ export class Lenis { wheel, touch, infinite = false, - orientation = 'vertical', // vertical, horizontal - gestureOrientation = orientation === 'horizontal' ? 'both' : 'vertical', // vertical, horizontal, both + orientation = 'vertical', // vertical, horizontal, both + gestureOrientation = orientation === 'vertical' ? 'vertical' : 'both', // vertical, horizontal, both — has no effect when orientation is 'both' onGesture, overscroll = true, autoRaf = true, @@ -203,6 +185,9 @@ export class Lenis { this.options.dimensions ) + this.x = new Axis('x', this) + this.y = new Axis('y', this) + // Setup class name this.updateClassName() @@ -228,7 +213,7 @@ export class Lenis { this.onPointerDown as EventListener ) - // Setup virtual scroll instance + // Setup gestures handler this.gesturesHandler = new GesturesHandler(eventsTarget as HTMLElement) this.gesturesHandler.on('gesture', this.onGesture) @@ -264,9 +249,10 @@ export class Lenis { ) } - // this.virtualScroll.destroy() this.gesturesHandler.destroy() this.dimensions.destroy() + this.x.destroy() + this.y.destroy() this.cleanUpClassName() @@ -283,8 +269,7 @@ export class Lenis { * @returns Unsubscribe function */ on(event: 'scroll', callback: ScrollCallback): () => void - on(event: 'gesture', callback: GestureCallback): () => void - on(event: LenisEvent, callback: ScrollCallback | GestureCallback) { + on(event: LenisEvent, callback: ScrollCallback) { return this.emitter.on(event, callback as (...args: unknown[]) => void) } @@ -295,8 +280,7 @@ export class Lenis { * @param callback Callback function */ off(event: 'scroll', callback: ScrollCallback): void - off(event: 'gesture', callback: GestureCallback): void - off(event: LenisEvent, callback: ScrollCallback | GestureCallback) { + off(event: LenisEvent, callback: ScrollCallback) { return this.emitter.off(event, callback as (...args: unknown[]) => void) } @@ -321,12 +305,7 @@ export class Lenis { } private checkOverflow() { - const property = this.isHorizontal ? 'overflow-x' : 'overflow-y' - const overflow = getComputedStyle(this.rootElement)[ - property as keyof CSSStyleDeclaration - ] as string - - this.isScrollable = !['hidden', 'clip'].includes(overflow) + this.isScrollable = this.activeAxis.cssOverflow } private onTransitionEnd = (event: TransitionEvent) => { @@ -339,19 +318,7 @@ export class Lenis { } private setScroll(scroll: number) { - // behavior: 'instant' bypasses the scroll-behavior CSS property - - if (this.isHorizontal) { - this.options.wrapper.scrollTo({ - left: scroll, - behavior: 'instant', - }) - } else { - this.options.wrapper.scrollTo({ - top: scroll, - behavior: 'instant', - }) - } + this.activeAxis.setScroll(scroll) } private onClick = (event: PointerEvent | MouseEvent) => { @@ -417,16 +384,14 @@ export class Lenis { let { deltaX, deltaY, event, type } = data - this.emitter.emit('gesture', { deltaX, deltaY, event, type }) + this.isTouch = type === 'touch' + this.isWheel = type === 'wheel' // keep zoom feature if (event.ctrlKey) return // @ts-expect-error if (event.lenisStopPropagation) return - this.isTouch = type === 'touch' - this.isWheel = type === 'wheel' - if (this.isTouch) { deltaX *= this.options.touch.multiplier! deltaY *= this.options.touch.multiplier! @@ -511,6 +476,50 @@ export class Lenis { return } + // 2D routing — gestureOrientation has no effect; deltaX drives x, deltaY drives y. + if (this.options.orientation === 'both') { + // @ts-expect-error + event.lenisStopPropagation = true + if (event.cancelable) event.preventDefault() + + const isTouchEnd = event.type === 'touchend' + const touchConfig = isTouchEnd + ? { + lerp: this.options.touch.lerp, + duration: this.options.touch.duration, + easing: this.options.touch.easing, + } + : { lerp: 1 } + const wheelConfig = { + lerp: this.options.wheel.lerp, + duration: this.options.wheel.duration, + easing: this.options.wheel.easing, + } + const config = this.isTouch ? touchConfig : wheelConfig + + let dx = deltaX + let dy = deltaY + if (isTouchEnd) { + const inertia = this.options.touch.inertia! + dx = Math.sign(dx) * Math.abs(this.x.velocity) ** inertia + dy = Math.sign(dy) * Math.abs(this.y.velocity) ** inertia + } + + if (dx !== 0) { + this.scrollAxisTo(this.x, this.x.targetScroll + dx, { + programmatic: false, + ...config, + }) + } + if (dy !== 0) { + this.scrollAxisTo(this.y, this.y.targetScroll + dy, { + programmatic: false, + ...config, + }) + } + return + } + let delta = deltaY if (this.options.gestureOrientation === 'both') { delta = Math.abs(deltaY) > Math.abs(deltaX) ? deltaY : deltaX @@ -591,13 +600,18 @@ export class Lenis { } if (this.isScrolling === false || this.isScrolling === 'native') { - const lastScroll = this.animatedScroll - this.animatedScroll = this.targetScroll = this.actualScroll - this.lastVelocity = this.velocity - this.velocity = this.animatedScroll - lastScroll - this.direction = Math.sign( - this.animatedScroll - lastScroll - ) as Lenis['direction'] + // Sync each axis to the browser's reported scroll position. In single-axis + // mode the inactive axis just re-reads 0 (or whatever the user dragged via a + // visible scrollbar); in `'both'` mode both axes track native scroll. + let anyVelocity = false + for (const axis of [this.x, this.y]) { + const lastScroll = axis.animatedScroll + axis.animatedScroll = axis.targetScroll = axis.actualScroll + axis.lastVelocity = axis.velocity + axis.velocity = axis.animatedScroll - lastScroll + axis.direction = Math.sign(axis.velocity) as 1 | -1 | 0 + if (axis.velocity !== 0) anyVelocity = true + } if (this.isScrollable) { this.isScrolling = 'native' @@ -605,22 +619,32 @@ export class Lenis { this.emit() - if (this.velocity !== 0) { + if (anyVelocity) { this._resetVelocityTimeout = setTimeout(() => { - this.reset() - this.emit() + if (this.isScrolling === 'native' || this.isScrolling === false) { + this.reset() + this.emit() + } + this._resetVelocityTimeout = null }, 400) // arbitrary timeout to reset the velocity } } } private reset() { + if (this._resetVelocityTimeout !== null) { + clearTimeout(this._resetVelocityTimeout) + this._resetVelocityTimeout = null + } + this.isScrolling = false - this.isTouch = undefined - this.isWheel = undefined - this.animatedScroll = this.targetScroll = this.actualScroll - this.lastVelocity = this.velocity = 0 - this.animate.stop() + this.x.reset() + this.y.reset() + } + + /** Whether any axis currently has an animation running. */ + private get isAnyAxisAnimating() { + return this.x.animate.isRunning || this.y.animate.isRunning } lock() { @@ -640,132 +664,220 @@ export class Lenis { const deltaTime = time - (this.time || time) this.time = time - this.animate.advance(deltaTime * 0.001) + const xActive = this.x.advance(deltaTime * 0.001) + const yActive = this.y.advance(deltaTime * 0.001) + + // If either axis animated this frame, flush both axes' positions to the wrapper + // in a single `scrollTo` call (instead of two per-axis writes). + if (xActive || yActive) { + this.flushScroll() + } if (this.options.autoRaf) { this._rafId = requestAnimationFrame(this.raf) } } + /** + * Apply the current per-axis scroll values to the wrapper in one call, only + * writing the coordinate for each axis that's live (per `orientation`). This + * avoids double-writes when both axes animate in `'both'` mode and avoids + * clobbering the user's manual scroll on the inactive axis in single-axis mode. + */ + private flushScroll() { + const opts: { left?: number; top?: number; behavior: ScrollBehavior } = { + behavior: 'instant', + } + if (this.options.orientation !== 'vertical') opts.left = this.x.scroll + if (this.options.orientation !== 'horizontal') opts.top = this.y.scroll + this.options.wrapper.scrollTo(opts) + } + /** * Scroll to a target value * - * @param target The target value to scroll to + * @param target Numeric target, scroll-keyword (`'top'`, `'bottom'`, …), CSS selector, + * `HTMLElement`, or `{ x?, y? }` to drive each axis independently. + * A bare number / element / selector targets the active axis (the vertical + * one in `orientation: 'both'` mode); pass `{ x, y }` to scroll both at once. * @param options The options for the scroll * * @example - * lenis.scrollTo(100, { - * offset: 100, - * duration: 1, - * easing: (t) => 1 - Math.cos((t * Math.PI) / 2), - * lerp: 0.1, - * onStart: () => { - * console.log('onStart') - * }, - * onComplete: () => { - * console.log('onComplete') - * }, - * }) + * lenis.scrollTo(100, { duration: 1 }) + * lenis.scrollTo('#section') + * lenis.scrollTo({ x: 200, y: 800 }) // 2D, dispatches to both axes */ scrollTo( - _target: number | string | HTMLElement, - { - offset = 0, - immediate = false, - programmatic = true, // called from outside of the class - lerp = programmatic ? this.options.wheel.lerp : undefined, - duration = programmatic ? this.options.duration : undefined, - easing = programmatic ? this.options.easing : undefined, - onStart, - onComplete, - userData, - }: ScrollToOptions = {} + target: number | string | HTMLElement, + options?: ScrollToOptions + ): void + scrollTo(target: { x?: number; y?: number }, options?: ScrollToOptions): void + scrollTo( + _target: number | string | HTMLElement | { x?: number; y?: number }, + options: ScrollToOptions = {} ) { - let target: number | string | HTMLElement = _target - let adjustedOffset = offset - - // keywords + // 2D dispatch — bare `{ x?, y? }` object (excluding HTMLElement) if ( - typeof target === 'string' && - ['top', 'left', 'start', '#'].includes(target) - ) { - target = 0 - } else if ( - typeof target === 'string' && - ['bottom', 'right', 'end'].includes(target) + typeof _target === 'object' && + _target !== null && + !(_target instanceof HTMLElement) ) { - target = this.limit - } else { - let node: Element | null = null + const { x, y } = _target + if (x !== undefined) this.scrollAxisTo(this.x, x, options) + if (y !== undefined) this.scrollAxisTo(this.y, y, options) + return + } - if (typeof target === 'string') { - // CSS selector - node = document.querySelector(target) + const offset = options.offset ?? 0 - if (!node) { - if (target === '#top') { - target = 0 - } else { - console.warn('Lenis: Target not found', target) - } - } - } else if (target instanceof HTMLElement && target?.nodeType) { - // Node element - node = target + // Keywords — single-axis semantics (active axis). `top`/`left`/`start`/`#` → 0, + // `bottom`/`right`/`end` → limit. Users wanting 2D keyword semantics pass `{ x, y }`. + if (typeof _target === 'string') { + if (['top', 'left', 'start', '#'].includes(_target)) { + this.scrollAxisTo(this.activeAxis, offset, options) + return + } + if (['bottom', 'right', 'end'].includes(_target)) { + this.scrollAxisTo( + this.activeAxis, + this.activeAxis.limit + offset, + options + ) + return } + } - if (node) { - if (this.options.wrapper !== window) { - // nested scroll offset correction - const wrapperRect = this.rootElement.getBoundingClientRect() - adjustedOffset -= this.isHorizontal - ? wrapperRect.left - : wrapperRect.top + // Resolve a selector / HTMLElement to a `node` + let node: Element | null = null + if (typeof _target === 'string') { + node = document.querySelector(_target) + if (!node) { + if (_target === '#top') { + this.scrollAxisTo(this.activeAxis, offset, options) + } else { + console.warn('Lenis: Target not found', _target) } + return + } + } else if (_target instanceof HTMLElement && _target.nodeType) { + node = _target + } - const rect = node.getBoundingClientRect() - - // Account for scroll-margin CSS property on the target element - const targetStyle = getComputedStyle(node) - const scrollMargin = this.isHorizontal - ? Number.parseFloat(targetStyle.scrollMarginLeft) - : Number.parseFloat(targetStyle.scrollMarginTop) - - // Account for scroll-padding CSS property on the scroll container - const containerStyle = getComputedStyle(this.rootElement) - const scrollPadding = this.isHorizontal - ? Number.parseFloat(containerStyle.scrollPaddingLeft) - : Number.parseFloat(containerStyle.scrollPaddingTop) - - target = - (this.isHorizontal ? rect.left : rect.top) + - this.animatedScroll - - (Number.isNaN(scrollMargin) ? 0 : scrollMargin) - - (Number.isNaN(scrollPadding) ? 0 : scrollPadding) + if (node) { + if (this.options.orientation === 'both') { + // 2D: scroll the element into view on both axes. + this.scrollAxisTo( + this.x, + this.resolveElementTarget(node, this.x, offset), + options + ) + this.scrollAxisTo( + this.y, + this.resolveElementTarget(node, this.y, offset), + options + ) + } else { + this.scrollAxisTo( + this.activeAxis, + this.resolveElementTarget(node, this.activeAxis, offset), + options + ) } + return + } + + // Bare number + if (typeof _target === 'number') { + this.scrollAxisTo(this.activeAxis, _target + offset, options) + } + } + + /** + * Resolve an `Element`'s bounding rect to a numeric scroll target on the given + * `axis`, accounting for wrapper offset (nested Lenis), `scroll-margin` on the + * target, `scroll-padding` on the container, and the caller-provided `offset`. + */ + private resolveElementTarget( + node: Element, + axis: Axis, + offset: number + ): number { + let adjustedOffset = offset + + if (this.options.wrapper !== window) { + // nested scroll offset correction + const wrapperRect = this.rootElement.getBoundingClientRect() + adjustedOffset -= axis.axis === 'x' ? wrapperRect.left : wrapperRect.top } - if (typeof target !== 'number') return + const rect = node.getBoundingClientRect() + + // Account for scroll-margin CSS property on the target element + const targetStyle = getComputedStyle(node) + const scrollMargin = + axis.axis === 'x' + ? Number.parseFloat(targetStyle.scrollMarginLeft) + : Number.parseFloat(targetStyle.scrollMarginTop) + + // Account for scroll-padding CSS property on the scroll container + const containerStyle = getComputedStyle(this.rootElement) + const scrollPadding = + axis.axis === 'x' + ? Number.parseFloat(containerStyle.scrollPaddingLeft) + : Number.parseFloat(containerStyle.scrollPaddingTop) + + return ( + (axis.axis === 'x' ? rect.left : rect.top) + + axis.animatedScroll - + (Number.isNaN(scrollMargin) ? 0 : scrollMargin) - + (Number.isNaN(scrollPadding) ? 0 : scrollPadding) + + adjustedOffset + ) + } + + /** + * Drive the given `axis` to a numeric `target`. The animation state machine — + * infinite-wrap, clamp, `immediate` vs animated branches, `onStart` / `onUpdate` / + * `onComplete` — all per-axis. Lenis-level state (`isScrolling`, `userData`, + * `emit`, scrollend dispatch) lives on `this` and is shared. + * + * @internal exposed for `Axis.scrollTo` to delegate. + */ + scrollAxisTo( + axis: Axis, + _target: number, + { + immediate = false, + programmatic = true, + lerp = programmatic ? this.options.wheel.lerp : undefined, + duration = programmatic ? this.options.duration : undefined, + easing = programmatic ? this.options.easing : undefined, + onStart, + onComplete, + userData, + }: ScrollToOptions = {} + ) { + let target = _target - target += adjustedOffset + console.log('scrollAxisTo', axis.axis, target) if (this.options.infinite) { if (programmatic) { - this.targetScroll = this.animatedScroll = this.scroll + axis.targetScroll = axis.animatedScroll = axis.scroll - const distance = target - this.animatedScroll + const distance = target - axis.animatedScroll - if (distance > this.limit / 2) { - target -= this.limit - } else if (distance < -this.limit / 2) { - target += this.limit + if (distance > axis.limit / 2) { + target -= axis.limit + } else if (distance < -axis.limit / 2) { + target += axis.limit } } } else { - target = clamp(0, target, this.limit) + target = clamp(0, target, axis.limit) } - if (target === this.targetScroll) { + if (target === axis.targetScroll) { onStart?.(this) onComplete?.(this) return @@ -774,9 +886,10 @@ export class Lenis { this.userData = userData ?? {} if (immediate) { - this.animatedScroll = this.targetScroll = target - this.setScroll(this.scroll) - this.reset() + axis.animatedScroll = axis.targetScroll = target + axis.setScroll(axis.scroll) + axis.reset() + if (!this.isAnyAxisAnimating) this.isScrolling = false this.preventNextNativeScrollEvent() this.emit() onComplete?.(this) @@ -789,7 +902,7 @@ export class Lenis { } if (!programmatic) { - this.targetScroll = target + axis.targetScroll = target } // flip to easing/time based animation if at least one of them is provided @@ -799,7 +912,7 @@ export class Lenis { duration = 1 } - this.animate.fromTo(this.animatedScroll, target, { + axis.animate.fromTo(axis.animatedScroll, target, { duration, easing, lerp, @@ -811,22 +924,23 @@ export class Lenis { this.isScrolling = 'smooth' // updated - this.lastVelocity = this.velocity - this.velocity = value - this.animatedScroll - this.direction = Math.sign(this.velocity) as Lenis['direction'] + axis.lastVelocity = axis.velocity + axis.velocity = value - axis.animatedScroll + axis.direction = Math.sign(axis.velocity) as 1 | -1 | 0 - this.animatedScroll = value - this.setScroll(this.scroll) + axis.animatedScroll = value + // DOM write is consolidated into a single `wrapper.scrollTo` per frame in `Lenis.raf`. if (programmatic) { // wheel during programmatic should stop it - this.targetScroll = value + axis.targetScroll = value } if (!completed) this.emit() if (completed) { - this.reset() + axis.reset() + if (!this.isAnyAxisAnimating) this.isScrolling = false this.emit() onComplete?.(this) this.userData = {} @@ -862,10 +976,16 @@ export class Lenis { } /** - * The limit which is the maximum scroll value + * The active scroll axis — `x` when `orientation` is `horizontal`, otherwise `y`. + * The single-axis scroll getters/setters on the instance delegate to it. */ - get limit() { - return this.dimensions.limit[this.isHorizontal ? 'x' : 'y'] + private get activeAxis() { + return this.isHorizontal ? this.x : this.y + } + + /** @internal the animation driving the active axis */ + private get animate() { + return this.activeAxis.animate } /** @@ -875,34 +995,82 @@ export class Lenis { return this.options.orientation === 'horizontal' } + /** + * The target scroll value + */ + get targetScroll() { + return this.activeAxis.targetScroll + } + set targetScroll(value: number) { + this.activeAxis.targetScroll = value + } + + /** + * The animated scroll value + */ + get animatedScroll() { + return this.activeAxis.animatedScroll + } + set animatedScroll(value: number) { + this.activeAxis.animatedScroll = value + } + + /** + * The current velocity of the scroll + */ + get velocity() { + return this.activeAxis.velocity + } + set velocity(value: number) { + this.activeAxis.velocity = value + } + + /** + * The last velocity of the scroll + */ + get lastVelocity() { + return this.activeAxis.lastVelocity + } + set lastVelocity(value: number) { + this.activeAxis.lastVelocity = value + } + + /** + * The direction of the scroll + */ + get direction() { + return this.activeAxis.direction + } + set direction(value: 1 | -1 | 0) { + this.activeAxis.direction = value + } + + /** + * The limit which is the maximum scroll value + */ + get limit() { + return this.activeAxis.limit + } + /** * The actual scroll value */ get actualScroll() { - // value browser takes into account - // it has to be this way because of DOCTYPE declaration - const wrapper = this.options.wrapper as Window | HTMLElement - - return this.isHorizontal - ? ((wrapper as Window).scrollX ?? (wrapper as HTMLElement).scrollLeft) - : ((wrapper as Window).scrollY ?? (wrapper as HTMLElement).scrollTop) + return this.activeAxis.actualScroll } /** * The current scroll value */ get scroll() { - return this.options.infinite - ? modulo(this.animatedScroll, this.limit) - : this.animatedScroll + return this.activeAxis.scroll } /** * The progress of the scroll relative to the limit */ get progress() { - // avoid progress to be NaN - return this.limit === 0 ? 1 : this.scroll / this.limit + return this.activeAxis.progress } /** @@ -949,13 +1117,6 @@ export class Lenis { } } - /** - * Check if lenis is smooth scrolling - */ - get isSmooth() { - return this.isScrolling === 'smooth' - } - /** * The class name applied to the wrapper element */ diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ab31bb98..1c265294 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -33,7 +33,7 @@ export type UserData = Record export type Scrolling = boolean | 'native' | 'smooth' -export type LenisEvent = 'scroll' | 'gesture' +export type LenisEvent = 'scroll' export type ScrollCallback = (lenis: Lenis) => void export type GestureCallback = (data: GestureData) => void @@ -44,7 +44,7 @@ export type GestureData = { type: 'wheel' | 'touch' } -export type Orientation = 'vertical' | 'horizontal' +export type Orientation = 'vertical' | 'horizontal' | 'both' export type GestureOrientation = 'vertical' | 'horizontal' | 'both' export type EasingFunction = (time: number) => number @@ -168,7 +168,12 @@ export type LenisOptions = { */ infinite?: boolean /** - * The orientation of the scrolling. Can be `vertical` or `horizontal` + * The orientation of the scrolling. Can be `vertical`, `horizontal`, or `both` (2D). + * + * When `both`, `lenis.x` and `lenis.y` each handle one axis and `gestureOrientation` + * has no effect (horizontal gestures drive `x`, vertical gestures drive `y`). The + * single-axis getters on `lenis` (`scroll`, `progress`, `scrollTo(n)`, …) alias the + * vertical axis. * @default vertical */ orientation?: Orientation diff --git a/playground/two-axis/style.css b/playground/two-axis/style.css index b9dbf912..0c8ea41f 100644 --- a/playground/two-axis/style.css +++ b/playground/two-axis/style.css @@ -49,4 +49,18 @@ body { /* html { overflow-y: hidden; -} */ \ No newline at end of file +} */ + +html, body { + overflow: hidden; + width: 100%; + overscroll-behavior: none; + height: 100svh; +} + +#grid { + overflow: auto; + height: 100%; + width: 100%; + overscroll-behavior: none; +} \ No newline at end of file diff --git a/playground/two-axis/test.ts b/playground/two-axis/test.ts index 2be4a3d0..85d745bc 100644 --- a/playground/two-axis/test.ts +++ b/playground/two-axis/test.ts @@ -1,9 +1,29 @@ import Lenis from 'lenis' -const lenis = new Lenis({}) +const lenis = new Lenis({ + wrapper: document.querySelector('#grid')!, + orientation: 'both', + // infinite: true, + touch: { + smooth: true, + }, + wheel: { + smooth: true, + }, + // onGesture: (data) => { + // console.log(data.type, data.deltaX, data.deltaY) + // }, +}) lenis.on('scroll', (lenis) => { - console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) + // console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) }) window.lenis = lenis + +// const wrapper = document.querySelector('#grid')! + +// wrapper.addEventListener('wheel', (e) => { +// // e.preventDefault() +// console.log('wheel', e.deltaX, e.deltaY) +// }) From 9f38087402a74d38eac77d289f682cb458815893 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Thu, 14 May 2026 10:15:24 +0200 Subject: [PATCH 02/25] phase2 --- MULTI-AXIS-PLAN.md | 8 +- packages/core/src/animate.ts | 7 +- packages/core/src/axis.ts | 28 ++++++- packages/core/src/lenis.ts | 112 +++++++++++++++++----------- playground/core/test.ts | 8 +- playground/two-axis/style.css | 8 +- playground/two-axis/test.ts | 19 ++++- playground/www/pages/two-axis.astro | 8 +- 8 files changed, 139 insertions(+), 59 deletions(-) diff --git a/MULTI-AXIS-PLAN.md b/MULTI-AXIS-PLAN.md index 8248ddcc..d74adeb5 100644 --- a/MULTI-AXIS-PLAN.md +++ b/MULTI-AXIS-PLAN.md @@ -88,11 +88,11 @@ Ordered so each is a discrete, independently-shippable unit. `→` marks depende 7. ✅ **`scrollTo({ x?, y? })` overload** + **`Axis.scrollTo(number, opts)`** — extracted the animation state machine into `Lenis.scrollAxisTo(axis, target, opts)` (`@internal` — operates on the given axis; Lenis-level state stays on `this`). Public `Lenis.scrollTo` is now overloaded: `(target: number | string | HTMLElement, opts?)` resolves on the active axis; `(target: { x?, y? }, opts?)` dispatches to each axis. `Axis.scrollTo(target: number, opts?)` is a thin wrapper around `scrollAxisTo(this, …)` — so `lenis.x.scrollTo(200)` works. Completion logic only clears `isScrolling = false` when **no axis is animating** (`isAnyAxisAnimating` guard) so finishing one axis doesn't kill another's animation. `Lenis.reset()` now resets both axes. 8. ✅ **`scrollTo(element | selector)` in 2D** — extracted `private resolveElementTarget(node, axis, offset)`. In `orientation: 'both'`, `lenis.scrollTo('#section')` (or an `HTMLElement`) resolves the element rect to a target *per axis* (scroll-margin / scroll-padding / wrapper-rect correction each computed on the right side) and dispatches to both axes simultaneously. Covers anchor-link navigation in 2D. Keywords (`'top'`, `'left'`, `'start'`, `'#'`, `'bottom'`, `'right'`, `'end'`) stay single-axis on the active (vertical) axis — for 2D keyword behaviour, pass `{ x: 0, y: 0 }`. -### Phase 2 — edges & polish +### Phase 2 — edges & polish — ✅ DONE -9. **Nested scroll in 2D** → 6. `isScrollableElement` / `allowNestedScroll` considering both axes. Rule: if a composed-path element can scroll in the gesture's dominant direction, defer the whole gesture (don't split nested + lenis across axes). Document the limitation. -10. **Classnames** — `lenis-scrolling` if *either* axis scrolling; `lenis-stopped` if *neither* scrollable. Decide whether to keep/extend `lenis-horizontal` / `window.lenis.horizontal` in 2D. -11. **Events / direction-velocity semantics** — no event-shape change; confirm `velocity` / `direction` meaning in 2D, document the `({ x, y }) => {}` pattern. +9. ✅ **Nested scroll in 2D** — `isScrollableElement` already checks both `deltaX`/`deltaY`; the per-element `data-lenis-prevent-*` check still uses the gesture's dominant direction (existing rule: "defer the whole gesture if a composed-path element handles the dominant direction" — kept). The 2D `onGesture` branch now uses **per-axis edge-aware** `stopPropagation` instead of always-stopPropagation: stops only when overscroll is off / `infinite` / nested wrapper *and* either axis is mid-scroll or pushing into a boundary. Plus per-axis `cssOverflow` gating on dispatch — `overflow-x: hidden` blocks x gestures but not programmatic `lenis.x.scrollTo`. +10. ✅ **Classnames** — `checkOverflow` now aggregates: `Lenis.isScrollable = liveAxes.some(a => a.cssOverflow)` (introduced `private get liveAxes()` keyed off `orientation`). So `lenis-stopped` is applied only when *no* live axis can scroll. `lenis-scrolling` / `lenis-smooth` already worked correctly (shared `isScrolling`). `window.lenis.horizontal` stays for `'horizontal'`-only — no new global for `'both'`. +11. ✅ **Events / direction-velocity semantics** — no event-shape change. JSDoc updated on `Lenis.scroll` / `targetScroll` / `animatedScroll` / `velocity` / `lastVelocity` / `direction` / `actualScroll` / `progress` / `limit` to clarify they alias the active axis and point readers at `lenis.x.*` / `lenis.y.*` for per-axis values. `isScrolling` docs note it stays truthy until *no* axis is animating. ### Phase 3 — ecosystem & docs diff --git a/packages/core/src/animate.ts b/packages/core/src/animate.ts index c52a8021..9a276ab5 100644 --- a/packages/core/src/animate.ts +++ b/packages/core/src/animate.ts @@ -1,6 +1,8 @@ import { clamp, damp } from './maths' import type { EasingFunction, FromToOptions, OnUpdateCallback } from './types' +const QUANTIZE = 10 + /** * Animate class to handle value animations with lerping or easing * @@ -41,7 +43,10 @@ export class Animate { this.value = this.from + (this.to - this.from) * easedProgress } else if (this.lerp) { this.value = damp(this.value, this.to, this.lerp * 60, deltaTime) - if (Math.round(this.value) === Math.round(this.to)) { + if ( + Math.round(this.value * QUANTIZE) / QUANTIZE === + Math.round(this.to * QUANTIZE) / QUANTIZE + ) { this.value = this.to completed = true } diff --git a/packages/core/src/axis.ts b/packages/core/src/axis.ts index 1b8356d5..50613221 100644 --- a/packages/core/src/axis.ts +++ b/packages/core/src/axis.ts @@ -36,6 +36,23 @@ export class Axis { this.animate.stop() } + /** + * Cached "is this axis scrollable per its CSS overflow" — read on every gesture, so + * we don't hit `getComputedStyle` per frame. Refreshed by {@link checkOverflow}, + * which `Lenis` invokes at construction and on `overflow` `transitionend`. + */ + isScrollable = true + + /** + * Re-read the live CSS `overflow` for this axis into {@link isScrollable}. Resets + * the axis if it just flipped to non-scrollable (so an in-flight animation halts). + * + * Returns `true` when {@link isScrollable} changed. + */ + checkOverflow() { + this.isScrollable = this.cssOverflow + } + /** * Reset all scroll state to the browser's current scroll position and stop the animation. */ @@ -87,7 +104,11 @@ export class Axis { : ((wrapper as Window).scrollY ?? (wrapper as HTMLElement).scrollTop) } - /** The current scroll value (wrapped to `limit` when `infinite`). */ + /** + * The current scroll value (wrapped to `limit` when `infinite`). Stays full-float — + * the browser quantizes the DOM write per device pixel ratio at `scrollTo` time, so + * downstream consumers (transforms, WebGL, etc.) get the full-precision value. + */ get scroll() { return this.lenis.options.infinite ? modulo(this.animatedScroll, this.limit) @@ -105,7 +126,10 @@ export class Axis { return this.limit === 0 ? 1 : this.scroll / this.limit } - /** Whether this axis's CSS `overflow` permits scrolling (not `hidden`/`clip`). */ + /** + * Live read of this axis's CSS `overflow` (not `hidden` / `clip`). Touches + * `getComputedStyle` — prefer the cached {@link isScrollable} on hot paths. + */ get cssOverflow() { const property = this.axis === 'x' ? 'overflow-x' : 'overflow-y' const value = getComputedStyle(this.lenis.rootElement)[ diff --git a/packages/core/src/lenis.ts b/packages/core/src/lenis.ts index f15b731f..141c5fc6 100644 --- a/packages/core/src/lenis.ts +++ b/packages/core/src/lenis.ts @@ -29,7 +29,6 @@ const defaultEasing = (t: number) => Math.min(1, 1.001 - 2 ** (-10 * t)) export class Lenis { private _isScrolling: Scrolling = false // true when scroll is animating - private _isScrollable = true // true if element is scrollable (computed from css overflow property) private _isLocked = false // true when user-initiated scroll (wheel/touch) is suppressed — toggled via lock()/unlock() private _preventNextNativeScrollEvent = false private _resetVelocityTimeout: ReturnType | null = null @@ -142,12 +141,12 @@ export class Lenis { lerp: 0.1, multiplier: 1, inertia: 2, - ...(this.isIOS - ? (touch?.ios ?? { - inertia: 1.7, - lerp: 0.05, - }) - : touch), // overwrite default values if iOS + ...touch, + ...(this.isIOS && + (touch?.ios ?? { + inertia: 1.7, + lerp: 0.05, + })), // overwrite default values if iOS }, infinite, gestureOrientation, @@ -161,6 +160,8 @@ export class Lenis { stopInertiaOnNavigate, } + console.log(touch, this.options.touch) + // set default duration and easing if not provided if ( this.options.wheel?.duration !== undefined || @@ -305,7 +306,8 @@ export class Lenis { } private checkOverflow() { - this.isScrollable = this.activeAxis.cssOverflow + this.x.checkOverflow() + this.y.checkOverflow() } private onTransitionEnd = (event: TransitionEvent) => { @@ -478,11 +480,38 @@ export class Lenis { // 2D routing — gestureOrientation has no effect; deltaX drives x, deltaY drives y. if (this.options.orientation === 'both') { - // @ts-expect-error - event.lenisStopPropagation = true + const isTouchEnd = event.type === 'touchend' + + let dx = deltaX + let dy = deltaY + if (isTouchEnd) { + const inertia = this.options.touch.inertia! + dx = Math.sign(dx) * Math.abs(this.x.velocity) ** inertia + dy = Math.sign(dy) * Math.abs(this.y.velocity) ** inertia + } + + // Per-axis consumption: an axis "consumes" the gesture if it's scrollable AND + // mid-range or pushing further into the boundary in the gesture's direction. + // Mirrors the single-axis overscroll-edge check below. + const consuming = (axis: Axis, delta: number) => + axis.isScrollable && + axis.limit > 0 && + ((axis.animatedScroll > 0 && axis.animatedScroll < axis.limit) || + (axis.animatedScroll === 0 && delta > 0) || + (axis.animatedScroll === axis.limit && delta < 0)) + + if ( + !this.options.overscroll || + this.options.infinite || + (this.options.wrapper !== window && + (consuming(this.x, dx) || consuming(this.y, dy))) + ) { + // @ts-expect-error + event.lenisStopPropagation = true + } + if (event.cancelable) event.preventDefault() - const isTouchEnd = event.type === 'touchend' const touchConfig = isTouchEnd ? { lerp: this.options.touch.lerp, @@ -497,21 +526,16 @@ export class Lenis { } const config = this.isTouch ? touchConfig : wheelConfig - let dx = deltaX - let dy = deltaY - if (isTouchEnd) { - const inertia = this.options.touch.inertia! - dx = Math.sign(dx) * Math.abs(this.x.velocity) ** inertia - dy = Math.sign(dy) * Math.abs(this.y.velocity) ** inertia - } - - if (dx !== 0) { + // Drive each axis independently, but only if it's scrollable. + // Programmatic `scrollTo` still works on a non-scrollable axis (matches the + // "scrollTo always runs" policy), only user-initiated gestures are gated. + if (dx !== 0 && this.x.isScrollable) { this.scrollAxisTo(this.x, this.x.targetScroll + dx, { programmatic: false, ...config, }) } - if (dy !== 0) { + if (dy !== 0 && this.y.isScrollable) { this.scrollAxisTo(this.y, this.y.targetScroll + dy, { programmatic: false, ...config, @@ -859,8 +883,6 @@ export class Lenis { ) { let target = _target - console.log('scrollAxisTo', axis.axis, target) - if (this.options.infinite) { if (programmatic) { axis.targetScroll = axis.animatedScroll = axis.scroll @@ -996,7 +1018,8 @@ export class Lenis { } /** - * The target scroll value + * The target scroll value (active axis — `y` in `'vertical'`/`'both'`, `x` in `'horizontal'`). + * In 2D mode read each axis directly via `lenis.x.targetScroll` / `lenis.y.targetScroll`. */ get targetScroll() { return this.activeAxis.targetScroll @@ -1006,7 +1029,7 @@ export class Lenis { } /** - * The animated scroll value + * The animated scroll value (active axis — see {@link targetScroll}). */ get animatedScroll() { return this.activeAxis.animatedScroll @@ -1016,7 +1039,8 @@ export class Lenis { } /** - * The current velocity of the scroll + * The current velocity of the scroll (active axis — see {@link targetScroll}). + * In 2D, each axis has its own velocity — `lenis.x.velocity` / `lenis.y.velocity`. */ get velocity() { return this.activeAxis.velocity @@ -1036,7 +1060,8 @@ export class Lenis { } /** - * The direction of the scroll + * The scroll direction on the active axis: `1` forward, `-1` backward, `0` idle. + * Per-axis: `lenis.x.direction` / `lenis.y.direction`. */ get direction() { return this.activeAxis.direction @@ -1046,35 +1071,38 @@ export class Lenis { } /** - * The limit which is the maximum scroll value + * The maximum scroll value for the active axis. */ get limit() { return this.activeAxis.limit } /** - * The actual scroll value + * The scroll value the browser currently reports for the active axis. */ get actualScroll() { return this.activeAxis.actualScroll } /** - * The current scroll value + * The current (animated) scroll value for the active axis. + * In 2D, read each axis directly via `lenis.x.scroll` / `lenis.y.scroll`. */ get scroll() { return this.activeAxis.scroll } /** - * The progress of the scroll relative to the limit + * Scroll progress (0..1) of the active axis relative to its `limit`. */ get progress() { return this.activeAxis.progress } /** - * Current scroll state + * Current scroll state: `'native'` while consuming a non-smooth native scroll, + * `'smooth'` while a Lenis animation is driving any axis, `false` when idle. + * In 2D, becomes `false` only once *no* axis is animating. */ get isScrolling() { return this._isScrolling @@ -1088,19 +1116,17 @@ export class Lenis { } /** - * Whether the root element's CSS overflow currently permits scrolling + * Whether the user can scroll: `true` when at least one live axis is scrollable + * (cached per-axis on `lenis.x.isScrollable` / `lenis.y.isScrollable`, refreshed + * at construction and on `overflow` `transitionend`). The `lenis-stopped` class is + * applied when this is `false`. */ get isScrollable() { - return this._isScrollable - } - - private set isScrollable(value: boolean) { - if (this._isScrollable !== value) { - this._isScrollable = value - this.reset() - this.emit() - this.updateClassName() - } + const orientation = this.options.orientation + if (orientation === 'horizontal') return this.x.isScrollable + if (orientation === 'both') + return this.x.isScrollable || this.y.isScrollable + return this.y.isScrollable } /** diff --git a/playground/core/test.ts b/playground/core/test.ts index 41b4ded4..e45e768f 100644 --- a/playground/core/test.ts +++ b/playground/core/test.ts @@ -47,7 +47,7 @@ const lenis = new Lenis({ mode: 'read', }, onGesture: (data, lenis) => { - // console.log(data) + console.log(data) // return { // ...data, // deltaX: data.deltaX * 2, @@ -67,7 +67,11 @@ const lenis = new Lenis({ // }) lenis.on('scroll', (lenis) => { - console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) + console.log({ + scroll: lenis.scroll, + actualScroll: lenis.actualScroll, + targetScroll: lenis.targetScroll, + }) // console.log('scroll', e) }) diff --git a/playground/two-axis/style.css b/playground/two-axis/style.css index 0c8ea41f..9885032d 100644 --- a/playground/two-axis/style.css +++ b/playground/two-axis/style.css @@ -5,10 +5,10 @@ body { #grid { display: grid; - grid-template-columns: repeat(5, 100vw); - grid-template-rows: repeat(5, 100vh); - width: 500vw; - height: 500vh; + grid-template-columns: repeat(var(--cols), 100vw); + grid-template-rows: repeat(var(--rows), 100svh); + /* width: 500vw; + height: 500vh; */ } .cell { diff --git a/playground/two-axis/test.ts b/playground/two-axis/test.ts index 85d745bc..bfbd97fd 100644 --- a/playground/two-axis/test.ts +++ b/playground/two-axis/test.ts @@ -3,9 +3,12 @@ import Lenis from 'lenis' const lenis = new Lenis({ wrapper: document.querySelector('#grid')!, orientation: 'both', - // infinite: true, + infinite: true, touch: { smooth: true, + // ios: { + // smooth: true, + // }, }, wheel: { smooth: true, @@ -17,6 +20,20 @@ const lenis = new Lenis({ lenis.on('scroll', (lenis) => { // console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) + console.log({ + scroll: lenis.y.scroll, + // rounded: Math.round(lenis.x.scroll), + actuallScroll: lenis.y.actualScroll, + }) + // console.log({ + // scroll: lenis.y.scroll, + // rounded: Math.round(lenis.y.scroll), + // actualScroll: lenis.y.actualScroll, + // }) + + if (lenis.x.scroll !== lenis.x.actualScroll) { + console.log('x is not actualScroll') + } }) window.lenis = lenis diff --git a/playground/www/pages/two-axis.astro b/playground/www/pages/two-axis.astro index 17d4c1cb..331ace55 100644 --- a/playground/www/pages/two-axis.astro +++ b/playground/www/pages/two-axis.astro @@ -14,12 +14,16 @@ const edgeFor = (x: number, y: number) => { } const cells = Array.from({ length: rows }, (_, y) => - Array.from({ length: cols }, (_, x) => ({ x, y, edge: edgeFor(x, y) })) + Array.from({ length: cols }, (_, x) => ({ + x: x % (cols - 1), + y: y % (rows - 1), + edge: edgeFor(x % (cols - 1), y % (rows - 1)), + })) ).flat() --- -
+
{ cells.map(({ x, y, edge }) => (
From ab7f3a48d98d78a0f77603da8df33720f48e0397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Thu, 14 May 2026 15:52:18 +0200 Subject: [PATCH 03/25] trigger build --- playground/www/pages/two-axis.astro | 2 ++ 1 file changed, 2 insertions(+) diff --git a/playground/www/pages/two-axis.astro b/playground/www/pages/two-axis.astro index 331ace55..37c9a24c 100644 --- a/playground/www/pages/two-axis.astro +++ b/playground/www/pages/two-axis.astro @@ -34,3 +34,5 @@ const cells = Array.from({ length: rows }, (_, y) =>
+ + From 3b423d07321a876aa24739ffdd47772f0211ba29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Thu, 14 May 2026 18:32:00 +0200 Subject: [PATCH 04/25] tweak --- .claude/settings.local.json | 3 +- playground/two-axis/static.html | 29 +++++++ playground/two-axis/style.css | 112 ++++++++++++++++++++++++++++ playground/two-axis/test.ts | 69 +++++++++++++++++ playground/www/pages/two-axis.astro | 29 +++++++ 5 files changed, 241 insertions(+), 1 deletion(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index fc84ae30..36402b10 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,8 @@ "Bash(npx tsdown *)", "Bash(npx @biomejs/biome check packages/core/src)", "Bash(npx @biomejs/biome check --write packages/core/src/lenis.ts packages/core/src/axis.ts)", - "Bash(npx @biomejs/biome check --write packages/core/src)" + "Bash(npx @biomejs/biome check --write packages/core/src)", + "Bash(Select-Object -ExpandProperty FullName)" ] } } diff --git a/playground/two-axis/static.html b/playground/two-axis/static.html index 280f6b1d..36ada0be 100644 --- a/playground/two-axis/static.html +++ b/playground/two-axis/static.html @@ -1,6 +1,7 @@ +
@@ -27,5 +28,33 @@ }
+ + diff --git a/playground/two-axis/style.css b/playground/two-axis/style.css index 9885032d..34c0536a 100644 --- a/playground/two-axis/style.css +++ b/playground/two-axis/style.css @@ -63,4 +63,116 @@ html, body { height: 100%; width: 100%; overscroll-behavior: none; +} + +#tweak { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + font-family: monospace; + color: white; + background: rgba(20, 20, 20, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + border-top: 1px solid rgba(255, 255, 255, 0.12); + padding-bottom: env(safe-area-inset-bottom); + touch-action: manipulation; + transition: transform 0.25s ease; + transform: translateY(0); +} + +#tweak[data-open='false'] { + transform: translateY(calc(100% - 22px)); +} + +#tweak-toggle { + appearance: none; + background: transparent; + border: 0; + color: inherit; + font: inherit; + width: 100%; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + cursor: pointer; + text-transform: uppercase; + letter-spacing: 0.08em; + font-size: 9px; + opacity: 0.7; +} + +.tweak-handle { + display: inline-block; + width: 24px; + height: 3px; + border-radius: 2px; + background: rgba(255, 255, 255, 0.35); +} + +#tweak-body { + display: flex; + flex-direction: column; + gap: 4px; + padding: 2px 10px 8px; +} + +.tweak-row { + display: grid; + grid-template-columns: 56px 1fr 36px; + align-items: center; + gap: 8px; + font-size: 11px; + height: 22px; +} + +.tweak-label { + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.65; + font-size: 10px; +} + +.tweak-row input[type='range'] { + width: 100%; + accent-color: #e30613; + height: 18px; + margin: 0; +} + +.tweak-row output { + text-align: right; + font-variant-numeric: tabular-nums; + font-size: 11px; + opacity: 0.9; +} + +.tweak-actions { + display: flex; + gap: 6px; + margin-top: 2px; +} + +.tweak-actions button { + flex: 1; + appearance: none; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + color: white; + font: inherit; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 4px 8px; + border-radius: 4px; + min-height: 24px; + cursor: pointer; +} + +.tweak-actions button:active { + background: rgba(255, 255, 255, 0.14); } \ No newline at end of file diff --git a/playground/two-axis/test.ts b/playground/two-axis/test.ts index bfbd97fd..edc02e94 100644 --- a/playground/two-axis/test.ts +++ b/playground/two-axis/test.ts @@ -18,6 +18,75 @@ const lenis = new Lenis({ // }, }) +const tweak = document.querySelector('#tweak') +if (!tweak) throw new Error('#tweak not found — is the panel markup in the page?') + +const toggle = tweak.querySelector('#tweak-toggle')! +const reset = tweak.querySelector('#tweak-reset')! +const copy = tweak.querySelector('#tweak-copy')! +const inputs = tweak.querySelectorAll('input[data-key]') + +type TouchKey = 'lerp' | 'inertia' | 'multiplier' + +const initial: Record = { + lerp: lenis.options.touch!.lerp as number, + inertia: lenis.options.touch!.inertia as number, + multiplier: lenis.options.touch!.multiplier as number, +} + +const format = (key: TouchKey, value: number) => + key === 'lerp' ? value.toFixed(2) : value.toFixed(2) + +const sync = (key: TouchKey, value: number) => { + ;(lenis.options.touch as Record)[key] = value + const out = tweak.querySelector(`output[data-out="${key}"]`) + if (out) out.value = format(key, value) +} + +const setInput = (key: TouchKey, value: number) => { + const input = tweak.querySelector(`input[data-key="${key}"]`) + if (!input) return + input.value = String(value) + sync(key, value) +} + +;(['lerp', 'inertia', 'multiplier'] as TouchKey[]).forEach((key) => { + setInput(key, initial[key]) +}) + +inputs.forEach((input) => { + input.addEventListener('input', () => { + const key = input.dataset.key as TouchKey + sync(key, parseFloat(input.value)) + }) +}) + +toggle.addEventListener('click', () => { + const open = tweak.dataset.open !== 'false' + tweak.dataset.open = String(!open) + toggle.setAttribute('aria-expanded', String(!open)) +}) + +reset.addEventListener('click', () => { + ;(['lerp', 'inertia', 'multiplier'] as TouchKey[]).forEach((key) => { + setInput(key, initial[key]) + }) +}) + +copy.addEventListener('click', async () => { + const snippet = `touch: {\n smooth: true,\n lerp: ${format('lerp', (lenis.options.touch as { lerp: number }).lerp)},\n inertia: ${format('inertia', (lenis.options.touch as { inertia: number }).inertia)},\n multiplier: ${format('multiplier', (lenis.options.touch as { multiplier: number }).multiplier)},\n}` + try { + await navigator.clipboard.writeText(snippet) + const original = copy.textContent + copy.textContent = 'copied' + setTimeout(() => { + copy.textContent = original + }, 1200) + } catch { + console.log(snippet) + } +}) + lenis.on('scroll', (lenis) => { // console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) console.log({ diff --git a/playground/www/pages/two-axis.astro b/playground/www/pages/two-axis.astro index 37c9a24c..8fb7689d 100644 --- a/playground/www/pages/two-axis.astro +++ b/playground/www/pages/two-axis.astro @@ -32,6 +32,35 @@ const cells = Array.from({ length: rows }, (_, y) => )) }
+ + + From d3b55e39830278e7c7104d5522c8328bee5df23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Roche?= Date: Thu, 14 May 2026 18:34:55 +0200 Subject: [PATCH 05/25] maximum inertia --- bash.exe.stackdump | 44 ++++++++++++++--------------- playground/two-axis/static.html | 2 +- playground/www/pages/two-axis.astro | 2 +- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/bash.exe.stackdump b/bash.exe.stackdump index 79487b07..364d0869 100644 --- a/bash.exe.stackdump +++ b/bash.exe.stackdump @@ -1,28 +1,28 @@ Stack trace: Frame Function Args -0007FFFFB010 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFF9F10) msys-2.0.dll+0x1FE8E -0007FFFFB010 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFB2E8) msys-2.0.dll+0x67F9 -0007FFFFB010 000210046832 (000210286019, 0007FFFFAEC8, 000000000000, 000000000000) msys-2.0.dll+0x6832 -0007FFFFB010 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 -0007FFFFB010 000210068E24 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 -0007FFFFB2F0 00021006A225 (0007FFFFB020, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 +0007FFFFB840 00021005FE8E (000210285F68, 00021026AB6E, 000000000000, 0007FFFFA740) msys-2.0.dll+0x1FE8E +0007FFFFB840 0002100467F9 (000000000000, 000000000000, 000000000000, 0007FFFFBB18) msys-2.0.dll+0x67F9 +0007FFFFB840 000210046832 (000210286019, 0007FFFFB6F8, 000000000000, 000000000000) msys-2.0.dll+0x6832 +0007FFFFB840 000210068CF6 (000000000000, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28CF6 +0007FFFFB840 000210068E24 (0007FFFFB850, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x28E24 +0007FFFFBB20 00021006A225 (0007FFFFB850, 000000000000, 000000000000, 000000000000) msys-2.0.dll+0x2A225 End of stack trace Loaded modules: 000100400000 bash.exe -7FF808960000 ntdll.dll -7FF8077F0000 KERNEL32.DLL -7FF805240000 KERNELBASE.dll -7FF808730000 USER32.dll +7FF8B5C80000 ntdll.dll +7FF8B52A0000 KERNEL32.DLL +7FF8B2930000 KERNELBASE.dll +7FF8B4540000 USER32.dll 000210040000 msys-2.0.dll -7FF805B00000 win32u.dll -7FF8085C0000 GDI32.dll -7FF805BE0000 gdi32full.dll -7FF805B30000 msvcp_win.dll -7FF8057B0000 ucrtbase.dll -7FF806F00000 advapi32.dll -7FF806700000 msvcrt.dll -7FF808510000 sechost.dll -7FF806FC0000 RPCRT4.dll -7FF804740000 CRYPTBASE.DLL -7FF805A50000 bcryptPrimitives.dll -7FF8085F0000 IMM32.DLL +7FF8B2D30000 win32u.dll +7FF8B59F0000 GDI32.dll +7FF8B26B0000 gdi32full.dll +7FF8B37D0000 msvcp_win.dll +7FF8B2D60000 ucrtbase.dll +7FF8B3CD0000 advapi32.dll +7FF8B4740000 msvcrt.dll +7FF8B5A70000 sechost.dll +7FF8B3F30000 RPCRT4.dll +7FF8B1BB0000 CRYPTBASE.DLL +7FF8B2EB0000 bcryptPrimitives.dll +7FF8B3C30000 IMM32.DLL diff --git a/playground/two-axis/static.html b/playground/two-axis/static.html index 36ada0be..4d8fa2f4 100644 --- a/playground/two-axis/static.html +++ b/playground/two-axis/static.html @@ -42,7 +42,7 @@