diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 644f9de2..41c0ebbb 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -6,7 +6,53 @@ "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)", + "Bash(Select-Object -ExpandProperty FullName)", + "mcp__chrome-devtools__new_page", + "mcp__chrome-devtools__list_console_messages", + "mcp__chrome-devtools__evaluate_script", + "mcp__chrome-devtools__navigate_page", + "mcp__chrome-devtools__take_screenshot", + "Bash(bun --version)", + "Bash(curl -fsSL https://bun.sh/install)", + "Bash(bash)", + "Bash(~/.bun/bin/bun --version)", + "Bash(timeout 8 bun run --parallel dev:build dev:playground)", + "Bash(git add *)", + "Bash(git reset *)", + "Bash(git commit -m ' *)", + "Bash(git push *)", + "Bash(git fetch *)", + "Bash(gh repo *)", + "Bash(gh pr create --base v2 --head v2-multi-axis --title 'v2 multi-axis' --body ' *)", + "Bash(git *)", + "Bash(awk '/scrollAxisTo\\\\\\(/{f=1} f{print NR\": \"$0} /^ \\\\}$/{if\\(f\\)c++; if\\(c>=1 && f && /^ \\\\}$/\\){exit}}' /Users/clement/Desktop/darkroom/lenis/packages/core/src/lenis.ts)", + "Bash(awk 'NR>=840 && NR<=950 {print NR\": \"$0}' /Users/clement/Desktop/darkroom/lenis/packages/core/src/lenis.ts)", + "Bash(grep -nA3 -B3 \"emit\\('gesture'\")", + "Bash(cd *)", + "Bash(node -e \"console.log\\(require\\('./node_modules/lenis/package.json'\\).types || require\\('./node_modules/lenis/package.json'\\).exports\\)\")", + "Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(JSON.stringify\\(p.workspaces,null,2\\)\\)\")", + "Bash(node -e \"const p=require\\('./playground/package.json'\\); console.log\\(JSON.stringify\\({name:p.name,deps:p.dependencies},null,2\\)\\)\")", + "Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(JSON.stringify\\({main:p.main,module:p.module,types:p.types,exportsDot:p.exports&&p.exports['.']},null,2\\)\\)\")", + "Bash(pnpm --filter lenis typecheck)", + "Bash(bun run *)", + "Bash(gh pr *)", + "Bash(node --input-type=module -e ' *)", + "Bash(node -e \"const p=require\\('./package.json'\\); console.log\\('react:', JSON.stringify\\(p.exports['./react']\\)\\); console.log\\('css:', JSON.stringify\\(p.exports['./dist/lenis.css'] ?? 'via ./dist/* or files'\\)\\)\")", + "Bash(node -e \"const p=require\\('./package.json'\\); console.log\\(Object.keys\\(p.exports\\)\\)\")", + "Read(//tmp/**)", + "Bash(pkill -f \"astro dev\")", + "Bash(pkill -f \"astro/dist/cli\")", + "mcp__plugin_chrome-devtools-mcp_chrome-devtools__new_page", + "mcp__plugin_chrome-devtools-mcp_chrome-devtools__evaluate_script", + "mcp__plugin_chrome-devtools-mcp_chrome-devtools__take_screenshot", + "mcp__plugin_chrome-devtools-mcp_chrome-devtools__list_console_messages" ] } } diff --git a/.gitignore b/.gitignore index f512fb1e..798f5212 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,6 @@ dist/ .tldr/ .tldrignore -./claude \ No newline at end of file +./claude +# Windows msys crash dumps +*.stackdump diff --git a/LENIS-API.md b/LENIS-API.md new file mode 100644 index 00000000..c2701c5c --- /dev/null +++ b/LENIS-API.md @@ -0,0 +1,281 @@ +# Lenis API — behavioral source of truth + +This document defines the **intended contract** for every public property, getter/setter, +and method on the `Lenis` instance, plus every `scrollTo` option. It is the source of +truth: where the code disagrees with a rule here, the **code** is wrong and should be +brought into line. Divergences known at the time of writing are flagged with **⚠ Code +status**. + +Conventions: + +- **Active axis** = `x` when `orientation: 'horizontal'`, otherwise `y`. All single-axis + getters/setters on `lenis` delegate to it. In `orientation: 'both'`, read each axis + directly (`lenis.x.*` / `lenis.y.*`) for per-axis precision. +- **Gesture** = user-initiated wheel/touch input. **Programmatic** = `scrollTo` (or + anything driven from code). + +--- + +## `scrollTo(target, options)` + +Scroll to a target. One call is **one logical operation**: its callbacks, `userData`, and +`lock` apply once for the whole call even when it drives both axes. + +```ts +lenis.scrollTo(500) // active axis, animated +lenis.scrollTo('#section', { offset: -80 }) +lenis.scrollTo('bottom', { immediate: true }) +lenis.scrollTo({ x: 200, y: 800 }) // 2D — drives both axes as one operation +``` + +**Targets** + +| Target | Resolves to | +| --- | --- | +| `number` | active axis → `target + offset` | +| `'top'` / `'left'` / `'start'` / `'#'` | active axis → `0 + offset` | +| `'bottom'` / `'right'` / `'end'` | active axis → `limit + offset` | +| CSS selector / `HTMLElement` | element's scroll position (`+ offset`); both axes in `'both'` mode | +| `{ x?, y? }` | the given axes; omitted axis untouched | + +**Rule — one call = one `onStart`, one `onComplete`.** Even a 2D `{ x, y }` target fires +each callback exactly once: `onStart` when the first axis starts, `onComplete` when the +last axis settles. Interrupting the operation never fires `onComplete`. + +### `scrollTo` options + +#### `offset?: number | { x?, y? }` — default `0` + +**Rule:** shift the resolved target by `offset` pixels. Applies to **every** target form, +including 2D. A scalar applies to every driven axis; pass `{ x?, y? }` to offset each axis +independently (a missing axis key is `0`). For single-axis targets the active axis's +offset is used. + +```ts +lenis.scrollTo('#section', { offset: -100 }) // stop 100px above the section +lenis.scrollTo({ x: 200, y: 800 }, { offset: -50 }) // scalar → x: 150, y: 750 +lenis.scrollTo({ x: 200, y: 800 }, { offset: { y: -80 } }) // per-axis → x: 200, y: 720 +``` + +#### `immediate?: boolean` — default `false` + +**Rule:** jump to the target instantly, skipping the animation. + +- No interpolation; `animatedScroll`/`targetScroll` are set to the target in one step. +- Fires `onStart` then `onComplete` synchronously, then dispatches `scrollend` on the + next frame. +- Ignores `duration` / `easing` / `lerp`. + +```ts +lenis.scrollTo(0, { immediate: true }) // snap to top, no tween +``` + +#### `lock?: boolean` — default `false` + +**Rule:** prevent user gestures for the lifetime of *this* programmatic scroll. + +- Sets `isLocked = true` when the operation starts, `isLocked = false` when it completes. +- All-or-nothing and instance-wide (see [`isLocked`](#islocked--getset-boolean)); a + partial `scrollTo({ x }, { lock: true })` still locks the whole instance. +- Programmatic scrolls still run while locked — `lock` only gates **gestures**. + +```ts +lenis.scrollTo(1000, { lock: true }) // user can't wheel-interrupt this scroll +``` + +#### `programmatic?: boolean` — default `true` + +**Rule:** internal flag for whether the scroll came from code (`true`) or from a user +gesture (`false`). Consumers should not normally set it. + +- `true` (default for `scrollTo`): drives `targetScroll` to follow the animation, uses + the programmatic `duration`/`easing`/`lerp` defaults. +- `false`: used internally by the gesture handler so wheel/touch feed the animation + without overriding the user's intent. + +#### `duration?: number` (seconds) / `easing?: (t) => number` / `lerp?: number` + +**Rule:** choose the interpolation for the animated (non-`immediate`) path. + +- Provide **`duration`** (optionally with `easing`) for time-based easing. Default easing: + `(t) => Math.min(1, 1.001 - 2 ** (-10 * t))`. +- Provide **`lerp`** (0–1) for framerate-independent damping instead. +- If neither is given, the instance defaults apply (`options.duration` / `options.easing`, + else `wheel.lerp`). + +```ts +lenis.scrollTo(500, { duration: 1.2, easing: (t) => t }) +lenis.scrollTo(500, { lerp: 0.1 }) +``` + +#### `onStart?: (lenis) => void` / `onComplete?: (lenis) => void` + +**Rule:** lifecycle callbacks for the operation, fired **once** each (see the one-call rule +above). `immediate` scrolls fire both synchronously; `onComplete` never fires if the +operation is interrupted. + +#### `userData?: UserData` — default `{}` + +See [`userData`](#userdata--getset-userdata). Tags the operation so scroll listeners can +tell what triggered it. + +```ts +lenis.scrollTo('#section', { userData: { source: 'nav-click' } }) +lenis.on('scroll', () => console.log(lenis.userData.source)) +``` + +--- + +## State & control + +### `userData` — get/set `UserData` + +**Rule:** carries external context about *what triggered the current programmatic scroll*, +forwarded through scroll callbacks. Set on `scrollTo`, cleared on completion, and +overwritten by the next `scrollTo`. + +- Set once per `scrollTo` (shared across both axes in 2D); stays readable until the whole + operation completes. +- Cleared back to `{}` on completion, and replaced wholesale by the next `scrollTo`. +- It describes *trigger context* (e.g. `{ source: 'nav' }`), not scroll state. +- A gesture that interrupts a programmatic scroll may leave the previous tag in place + until the next `scrollTo` — acceptable, since the next call replaces it. + +### `isLocked` — get/set `boolean` — default `false` + +**Rule:** when `true`, user gestures (wheel/touch) cannot scroll. Programmatic `scrollTo` +still runs. Instance-wide and all-or-nothing — both axes are locked together or neither is. + +- Set it directly, or via [`lock()`](#lock--unlock) / [`unlock()`](#lock--unlock), or for + the lifetime of a `scrollTo({ lock: true })`. +- Toggling it maintains the `lenis-locked` class on the root element. + +```ts +lenis.isLocked = true // suppress gestures; scrollTo still works +lenis.scrollTo(1000) // runs +lenis.isLocked = false +``` + +### `lock()` / `unlock()` + +**Rule:** imperative shortcuts for `isLocked = true` / `isLocked = false`. + +### `isScrolling` — get `boolean | 'native' | 'smooth'` + +**Rule:** current scroll state. + +- `'smooth'` — a Lenis animation is driving at least one axis. +- `'native'` — consuming a non-smooth native scroll. +- `false` — idle. In 2D, becomes `false` only once **no** axis is animating. + +### `isSmooth` — get `boolean` + +**Rule:** `true` when Lenis is smooth-scrolling — shorthand for `isScrolling === 'smooth'`. + +### `isScrollable` — get `boolean` + +**Rule:** whether the user can scroll, derived from the wrapper's CSS `overflow`. `true` +when at least one live axis is scrollable (not `hidden` / `clip`). Drives the +`lenis-stopped` class (applied when `false`). Cached per-axis on +`lenis.x.isScrollable` / `lenis.y.isScrollable`, refreshed at construction and on +`overflow` `transitionend`. + +--- + +## Scroll values (active-axis delegates) + +In `orientation: 'both'`, these alias the **vertical** axis; read `lenis.x.*` / `lenis.y.*` +for the other axis. + +### `scroll` — get `number` +**Rule:** the current animated scroll value of the active axis (full-float; wrapped to +`limit` when `infinite`). + +### `targetScroll` — get/set `number` +**Rule:** the value the active axis is animating toward. Setting it is low-level; prefer +`scrollTo`. + +### `animatedScroll` — get/set `number` +**Rule:** the interpolated scroll value of the active axis (what `scroll` reads pre-wrap). + +### `actualScroll` — get `number` +**Rule:** the scroll value the **browser** currently reports for the active axis +(`scrollY`/`scrollTop` or `scrollX`/`scrollLeft`). + +### `velocity` — get/set `number` +**Rule:** current scroll velocity (delta since last frame) on the active axis. Per-axis: +`lenis.x.velocity` / `lenis.y.velocity`. + +### `lastVelocity` — get/set `number` +**Rule:** the velocity from the previous frame on the active axis. + +### `direction` — get/set `1 | -1 | 0` +**Rule:** scroll direction of the active axis — `1` forward, `-1` backward, `0` idle. + +### `progress` — get `number` +**Rule:** scroll progress `0..1` of the active axis relative to its `limit` (`1` when +`limit` is `0`). + +### `limit` — get `number` +**Rule:** the maximum scroll value for the active axis. + +--- + +## Axes & geometry + +### `x` / `y` — readonly `Axis` +**Rule:** the per-axis scroll engines. In 2D each owns its own +`scroll`/`targetScroll`/`velocity`/`direction`/`limit`/`isScrollable` and its own +animation. Always available; only the active one is used in single-axis modes. + +### `rootElement` — get `HTMLElement` +**Rule:** the element Lenis is mounted on — the `wrapper`, or `document.documentElement` +when the wrapper is `window`. Carries the `lenis-*` state classes. + +### `className` — get `string` +**Rule:** the space-separated class list reflecting state, applied to `rootElement`: +`lenis`, plus `lenis-smooth`, `lenis-scrolling`, `lenis-stopped`, `lenis-locked` as the +matching state holds. + +### `isHorizontal` — get `boolean` +**Rule:** `true` when `orientation === 'horizontal'` (i.e. the active axis is `x`). + +### `dimensions` — readonly `Dimensions` +**Rule:** live wrapper/content size and per-axis scroll `limit`, kept in sync by a +`ResizeObserver`. Read `dimensions.width` / `dimensions.height` / `dimensions.limit`. + +### `options` — the resolved instance options +**Rule:** the merged, defaulted `LenisOptions` the instance is running with. + +--- + +## Input/runtime state + +### `isTouch` — `boolean | undefined` +**Rule:** whether the **last** gesture was touch. `undefined` until the first gesture. + +### `isWheel` — `boolean | undefined` +**Rule:** whether the **last** gesture was a wheel. `undefined` until the first gesture. + +### `time` — `number` +**Rule:** timestamp (ms) of the most recent `raf` tick. + +--- + +## Methods + +### `on(event, callback)` / `off(event, callback)` +**Rule:** subscribe / unsubscribe to `'scroll'` (`(lenis) => void`) or `'gesture'` +(`(data) => void`). `on` returns an unsubscribe function. + +### `raf(time)` +**Rule:** advance the animation by the clock `time` (ms). Called automatically when +`autoRaf: true`; otherwise drive it yourself from a `requestAnimationFrame`/Tempus loop. + +### `resize()` +**Rule:** force a re-measure of wrapper/content dimensions (normally automatic via the +`ResizeObserver`). + +### `destroy()` +**Rule:** tear down — remove listeners, stop the raf loop, disconnect observers. The +instance is unusable afterward. diff --git a/MULTI-AXIS-PLAN.md b/MULTI-AXIS-PLAN.md new file mode 100644 index 00000000..d74adeb5 --- /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 — ✅ DONE + +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 + +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..9592731d 100644 --- a/V2-ROADMAP.md +++ b/V2-ROADMAP.md @@ -117,8 +117,9 @@ The CSS is the source of truth: Lenis observes the root's overflow and reacts. U ### lenis/react -- [ ] Deprecate `root` option — don't target window, just forward instance. Maybe `children` detection can help -- [ ] Use `useSyncExternalStore` for state management +- [x] Split `root` into two orthogonal props: `root` (target window, render no wrapper divs) and `rootContext` (register in the global store so `useLenis` reaches it anywhere). `rootContext` defaults to `root`. Removes the overloaded `root="asChild"` string. +- [x] Use `useSyncExternalStore` for state management (`store.ts`) +- [x] Named instances: `` → `useLenis('sidebar')`. The single-slot global store became a keyed registry; the global root is just the entry under `ROOT_KEY`, so `rootContext` and `name` share one mechanism. --- @@ -176,12 +177,17 @@ 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: **core mechanics are implemented and verified working.** The `Axis` class (`packages/core/src/axis.ts`) is clean — per-axis state, `checkOverflow`, `reset`, `advance`, `scrollTo`, `scroll`/`limit`/`progress`. `Lenis` delegates to `this.x` / `this.y`, and `orientation: 'both'` is wired through gesture routing, scroll emission, the single per-frame DOM write, `scrollTo`, and `isScrollable`. `lenis/snap` is 2D-aware (per-axis `align`, 2D candidate selection). Verified in a browser on `playground/two-axis`: diagonal `scrollTo`, combined-delta wheel (both axes), DOM sync, and 2D snap to cell centers all behave. + +Remaining before stable: + +- ⏳ Real touch / trackpad-inertia testing on devices (only wheel + programmatic verified so far) +- ⏳ Resolve the top-level `duration` / `easing` scope question (see [Open design questions](#open-design-questions)) +- ⏳ Polished examples (the two-axis playground is still a raw test bed) ### ⏳ Auto CSS injection @@ -218,7 +224,7 @@ Warn in development mode when `infinite` is used on `html`/`body` (causes flicke ### Examples - ✅ `playground/touch` — native vs Lenis side-by-side for debugging `touch.smooth` on real devices -- 🚧 `playground/two-axis` — 5×5 viewport-sized grid for 2D scroll testing (corner cells colour-coded) +- 🚧 `playground/two-axis` — 5×5 viewport-sized grid for 2D scroll testing (corner cells colour-coded); functional test bed, not yet a polished example - ⏳ Nested scroll - ⏳ Horizontal scroll - ⏳ Framework integrations diff --git a/bash.exe.stackdump b/bash.exe.stackdump deleted file mode 100644 index 79487b07..00000000 --- a/bash.exe.stackdump +++ /dev/null @@ -1,28 +0,0 @@ -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 -End of stack trace -Loaded modules: -000100400000 bash.exe -7FF808960000 ntdll.dll -7FF8077F0000 KERNEL32.DLL -7FF805240000 KERNELBASE.dll -7FF808730000 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 diff --git a/package.json b/package.json index 995b8928..097a52e0 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,7 @@ }, "peerDependencies": { "@nuxt/kit": ">=3.0.0", - "react": ">=17.0.0", + "react": ">=18.0.0", "vue": ">=3.0.0" }, "peerDependenciesMeta": { diff --git a/packages/core/src/animate.ts b/packages/core/src/animate.ts index c52a8021..7c877de4 100644 --- a/packages/core/src/animate.ts +++ b/packages/core/src/animate.ts @@ -1,6 +1,13 @@ import { clamp, damp } from './maths' import type { EasingFunction, FromToOptions, OnUpdateCallback } from './types' +// Lerping asymptotically approaches the target but never exactly reaches it, +// so the raw value keeps trailing very long floating-point decimals. QUANTIZE +// snaps both value and target to a coarse grid (1/QUANTIZE) when comparing, so +// once they're within ~0.1 the animation is considered completed and snapped to +// the exact target — preventing an effectively infinite, never-ending animation. +const QUANTIZE = 10 + /** * Animate class to handle value animations with lerping or easing * @@ -41,7 +48,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 666cc49b..966c2e2d 100644 --- a/packages/core/src/axis.ts +++ b/packages/core/src/axis.ts @@ -1,110 +1,145 @@ 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() } + /** + * 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). + */ checkOverflow() { - if (this.cssOverflow) { - this.start() - } else { - this.stop() - } + if (this.isScrollable === this.cssOverflow) return + this.isScrollable = this.cssOverflow + this.reset() } - private start() { - if (!this.isStopped) return - - this.reset() - this.isStopped = false - this.emit() + /** + * 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 stop() { - if (this.isStopped) return + /** + * 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 + } - this.reset() - this.isStopped = true - this.emit() + /** + * Scroll this axis to a numeric target. Routes through `lenis.scrollTo` so + * the call gets the same single-fire orchestration (`onStart` / `onComplete`, + * `userData`, `lock`) as any other `scrollTo`. + */ + scrollTo(target: number, options?: ScrollToOptions) { + this.lenis.scrollTo( + this.axis === 'x' ? { x: target } : { y: 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`). 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) : 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) + /** + * 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)[ + 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..cc648970 100644 --- a/packages/core/src/lenis.ts +++ b/packages/core/src/lenis.ts @@ -1,10 +1,11 @@ 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 { + EventCallback, GestureCallback, GestureData, LenisEvent, @@ -30,18 +31,20 @@ 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 private _rafId: number | null = null + /** User data for the in-flight `scrollTo` operation. @see {@link userData} */ + private _userData: UserData = {} + /** Instance-wide lock — both axes are locked together or neither is. @see {@link isLocked} */ + private _isLocked = false /** - * 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 /** @@ -49,7 +52,10 @@ export class Lenis { */ time = 0 /** - * User data that will be forwarded through the scroll event + * User data carried by the in-flight `scrollTo` operation, forwarded through + * scroll callbacks. Set once per call — a 2D `scrollTo({ x, y })` shares one + * `userData` across both axes and keeps it readable until the whole + * operation completes (not wiped when the first axis lands). * * @example * lenis.scrollTo(100, { @@ -58,19 +64,13 @@ 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 + get userData(): UserData { + return this._userData + } + + set userData(value: UserData) { + this._userData = value + } /** * The options passed to the lenis instance */ @@ -78,20 +78,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 +97,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, @@ -134,9 +129,9 @@ export class Lenis { wrapper = window } - // if (wrapper === window) { - // content = document.documentElement - // } + if (wrapper === window) { + content = document.documentElement + } eventsTarget ??= wrapper @@ -160,12 +155,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, @@ -203,6 +198,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 +226,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 +262,10 @@ export class Lenis { ) } - // this.virtualScroll.destroy() this.gesturesHandler.destroy() this.dimensions.destroy() + this.x.destroy() + this.y.destroy() this.cleanUpClassName() @@ -284,7 +283,7 @@ export class Lenis { */ on(event: 'scroll', callback: ScrollCallback): () => void on(event: 'gesture', callback: GestureCallback): () => void - on(event: LenisEvent, callback: ScrollCallback | GestureCallback) { + on(event: LenisEvent, callback: EventCallback) { return this.emitter.on(event, callback as (...args: unknown[]) => void) } @@ -296,7 +295,7 @@ export class Lenis { */ off(event: 'scroll', callback: ScrollCallback): void off(event: 'gesture', callback: GestureCallback): void - off(event: LenisEvent, callback: ScrollCallback | GestureCallback) { + off(event: LenisEvent, callback: EventCallback) { return this.emitter.off(event, callback as (...args: unknown[]) => void) } @@ -321,12 +320,10 @@ 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.x.checkOverflow() + this.y.checkOverflow() + // Reflect a live overflow flip in the class names (e.g. `lenis-stopped`). + this.updateClassName() } private onTransitionEnd = (event: TransitionEvent) => { @@ -339,19 +336,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) => { @@ -414,19 +399,18 @@ export class Lenis { const data = this.options.onGesture?.(_data, this) ?? _data if (data === false) return + this.emitter.emit('gesture', data) 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 +495,73 @@ export class Lenis { return } + // 2D routing — gestureOrientation has no effect; deltaX drives x, deltaY drives y. + if (this.options.orientation === 'both') { + 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 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 + + // Drive each axis independently, but only if it's scrollable and the + // instance isn't locked. Programmatic `scrollTo` still works on a locked / + // non-scrollable axis (matches the "scrollTo always runs" policy); + // only user-initiated gestures are gated. + if (dx !== 0 && this.x.isScrollable && !this.isLocked) { + this.scrollAxisTo(this.x, this.x.targetScroll + dx, { + programmatic: false, + ...config, + }) + } + if (dy !== 0 && this.y.isScrollable && !this.isLocked) { + 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 +642,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,28 +661,44 @@ 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 scrolling — user-initiated wheel/touch gestures are suppressed on both + * axes. Programmatic `scrollTo` still runs (matches the "scrollTo always runs" + * policy). Pair with {@link unlock}. + */ lock() { this.isLocked = true } + /** Release the lock. */ unlock() { this.isLocked = false } @@ -640,147 +712,306 @@ 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, + 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 = {} + ) { + this.dispatchScrollTo(this.resolveScrollTargets(_target, options), options) + } + + /** + * Resolve a `scrollTo` target into the concrete `{ axis, target }` pairs to + * drive. A bare `{ x?, y? }` (or an element in `'both'` mode) yields one + * entry per axis; everything else yields a single entry on the active axis. + * Returns `[]` when there's nothing to scroll (e.g. unresolved selector). + */ + private resolveScrollTargets( + _target: number | string | HTMLElement | { x?: number; y?: number }, + options: ScrollToOptions + ): { axis: Axis; target: number }[] { + // Per-axis offset: a scalar applies to every axis, `{ x?, y? }` per axis. + const offsetFor = (axis: Axis) => this.resolveOffset(options.offset, axis) + const active = this.activeAxis + + // 2D dispatch — bare `{ x?, y? }` object (excluding HTMLElement). + if ( + typeof _target === 'object' && + _target !== null && + !(_target instanceof HTMLElement) + ) { + const { x, y } = _target + const targets: { axis: Axis; target: number }[] = [] + if (x !== undefined) + targets.push({ axis: this.x, target: x + offsetFor(this.x) }) + if (y !== undefined) + targets.push({ axis: this.y, target: y + offsetFor(this.y) }) + return targets + } + + // 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)) { + return [{ axis: active, target: offsetFor(active) }] + } + if (['bottom', 'right', 'end'].includes(_target)) { + return [{ axis: active, target: active.limit + offsetFor(active) }] + } + } + + // 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') { + return [{ axis: active, target: offsetFor(active) }] + } + console.warn('Lenis: Target not found', _target) + return [] + } + } else if (_target instanceof HTMLElement && _target.nodeType) { + node = _target + } + + if (node) { + if (this.options.orientation === 'both') { + // 2D: scroll the element into view on both axes. + return [ + { + axis: this.x, + target: this.resolveElementTarget(node, this.x, offsetFor(this.x)), + }, + { + axis: this.y, + target: this.resolveElementTarget(node, this.y, offsetFor(this.y)), + }, + ] + } + return [ + { + axis: active, + target: this.resolveElementTarget(node, active, offsetFor(active)), + }, + ] + } + + // Bare number + if (typeof _target === 'number') { + return [{ axis: active, target: _target + offsetFor(active) }] + } + + return [] + } + + /** Resolve the `offset` option to a number for a given axis. */ + private resolveOffset(offset: ScrollToOptions['offset'], axis: Axis): number { + if (typeof offset === 'number') return offset + if (!offset) return 0 + return (axis.axis === 'x' ? offset.x : offset.y) ?? 0 + } + + /** + * Run one logical `scrollTo` across one or more axes as a single operation: + * `userData`, `lock`, and the `onStart` / `onComplete` callbacks apply once + * for the whole call, not once per driven axis. Each axis still animates on + * its own `Animate` instance under the hood; this layer coordinates their + * shared lifecycle — `onStart` fires once when the first axis starts and + * `onComplete` once when the last axis settles. + */ + private dispatchScrollTo( + targets: { axis: Axis; target: number }[], { - 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, + lock = false, + ...options }: ScrollToOptions = {} ) { - let target: number | string | HTMLElement = _target - let adjustedOffset = offset + if (targets.length === 0) return - // keywords - if ( - typeof target === 'string' && - ['top', 'left', 'start', '#'].includes(target) - ) { - target = 0 - } else if ( - typeof target === 'string' && - ['bottom', 'right', 'end'].includes(target) - ) { - target = this.limit - } else { - let node: Element | null = null + // Operation-scoped userData: stays readable through every scroll callback + // until the whole operation finishes (not wiped when the first axis lands). + this.userData = userData ?? {} - if (typeof target === 'string') { - // CSS selector - node = document.querySelector(target) + // Operation-scoped lock: suppress gestures (on both axes) for the lifetime + // of the call, then release when it completes. + if (lock) this.isLocked = true - 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 - } + let started = false + let pending = targets.length - if (node) { - if (this.options.wrapper !== window) { - // nested scroll offset correction - const wrapperRect = this.rootElement.getBoundingClientRect() - adjustedOffset -= this.isHorizontal - ? wrapperRect.left - : wrapperRect.top - } + const handleStart = () => { + if (started) return + started = true + onStart?.(this) + } - 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) - } + const handleComplete = () => { + if (--pending > 0) return + this.userData = {} + if (lock) this.isLocked = false + onComplete?.(this) } - if (typeof target !== 'number') return + for (const { axis, target } of targets) { + this.scrollAxisTo(axis, target, { + ...options, + onStart: handleStart, + onComplete: handleComplete, + }) + } + } + + /** + * 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 + } + + 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 + ) + } - target += adjustedOffset + /** + * Drive a single `axis` to a numeric `target` — the per-axis animation state + * machine (infinite-wrap, clamp, `immediate` vs animated branches, the + * `onStart` / `onUpdate` / `onComplete` hooks). Lenis-level state + * (`isScrolling`, `emit`, scrollend dispatch) lives on `this` and is shared. + * + * Operation-level concerns — `userData`, `lock`, and firing the *caller's* + * `onStart` / `onComplete` exactly once — are owned by {@link dispatchScrollTo}, + * which is the only caller. The `onStart` / `onComplete` passed here are that + * orchestrator's per-axis settle hooks. + */ + private 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, + }: ScrollToOptions = {} + ) { + let target = _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 } - 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() + onStart?.(this) onComplete?.(this) - this.userData = {} requestAnimationFrame(() => { this.dispatchScrollendEvent() @@ -789,7 +1020,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 +1030,7 @@ export class Lenis { duration = 1 } - this.animate.fromTo(this.animatedScroll, target, { + axis.animate.fromTo(axis.animatedScroll, target, { duration, easing, lerp, @@ -811,25 +1042,25 @@ 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 = {} requestAnimationFrame(() => { this.dispatchScrollendEvent() @@ -862,10 +1093,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 } /** @@ -876,37 +1113,91 @@ export class Lenis { } /** - * The actual 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 actualScroll() { - // value browser takes into account - // it has to be this way because of DOCTYPE declaration - const wrapper = this.options.wrapper as Window | HTMLElement + get targetScroll() { + return this.activeAxis.targetScroll + } + set targetScroll(value: number) { + this.activeAxis.targetScroll = value + } - return this.isHorizontal - ? ((wrapper as Window).scrollX ?? (wrapper as HTMLElement).scrollLeft) - : ((wrapper as Window).scrollY ?? (wrapper as HTMLElement).scrollTop) + /** + * The animated scroll value (active axis — see {@link targetScroll}). + */ + get animatedScroll() { + return this.activeAxis.animatedScroll + } + set animatedScroll(value: number) { + this.activeAxis.animatedScroll = value + } + + /** + * 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 + } + 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 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 + } + set direction(value: 1 | -1 | 0) { + this.activeAxis.direction = value + } + + /** + * The maximum scroll value for the active axis. + */ + get limit() { + return this.activeAxis.limit } /** - * The current scroll value + * The scroll value the browser currently reports for the active axis. + */ + get actualScroll() { + return this.activeAxis.actualScroll + } + + /** + * 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.options.infinite - ? modulo(this.animatedScroll, this.limit) - : this.animatedScroll + 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() { - // avoid progress to be NaN - return this.limit === 0 ? 1 : this.scroll / this.limit + 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 @@ -920,40 +1211,41 @@ export class Lenis { } /** - * Whether the root element's CSS overflow currently permits scrolling + * Whether Lenis is currently smooth-scrolling (a Lenis animation is driving a + * scroll, on any axis) — i.e. {@link isScrolling} is `'smooth'`. */ - get isScrollable() { - return this._isScrollable + get isSmooth() { + return this.isScrolling === 'smooth' } - private set isScrollable(value: boolean) { - if (this._isScrollable !== value) { - this._isScrollable = value - this.reset() - this.emit() - this.updateClassName() - } + /** + * 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() { + 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 } /** - * Check if lenis is locked + * Whether user-initiated scrolling is suppressed. Instance-wide and + * all-or-nothing — both axes are locked together or neither is. Set via + * {@link lock} / {@link unlock} or a `scrollTo({ lock: true })` (for the + * lifetime of that scroll). Programmatic `scrollTo` runs regardless. */ get isLocked() { return this._isLocked } - private set isLocked(value: boolean) { - if (this._isLocked !== value) { - this._isLocked = value - this.updateClassName() - } - } - - /** - * Check if lenis is smooth scrolling - */ - get isSmooth() { - return this.isScrolling === 'smooth' + set isLocked(value: boolean) { + if (value === this._isLocked) return + this._isLocked = value + this.updateClassName() } /** diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index ab31bb98..0217eb44 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -36,6 +36,7 @@ export type Scrolling = boolean | 'native' | 'smooth' export type LenisEvent = 'scroll' | 'gesture' export type ScrollCallback = (lenis: Lenis) => void export type GestureCallback = (data: GestureData) => void +export type EventCallback = ScrollCallback | GestureCallback export type GestureData = { deltaX: number @@ -44,16 +45,17 @@ 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 export type ScrollToOptions = { /** - * The offset to apply to the target value + * The offset to apply to the target value. A single number applies to every + * driven axis; pass `{ x?, y? }` to offset each axis independently. * @default 0 */ - offset?: number + offset?: number | { x?: number; y?: number } /** * Skip the animation and jump to the target value immediately * @default false @@ -90,6 +92,11 @@ export type ScrollToOptions = { * User data that will be forwarded through the scroll event */ userData?: UserData + /** + * Lock the scroll to the target value + * @default false + */ + lock?: boolean } export interface WheelOptions { @@ -168,7 +175,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 @@ -180,7 +192,7 @@ export type LenisOptions = { /** * Called on every gesture event (wheel or touch) */ - onGesture?: (data: GestureData, lenis: Lenis) => GestureData | false + onGesture?: (data: GestureData, lenis: Lenis) => GestureData | false | void /** * Wether or not to enable overscroll on a nested Lenis instance, similar to CSS overscroll-behavior (https://developer.mozilla.org/en-US/docs/Web/CSS/overscroll-behavior) * @default true diff --git a/packages/react/README.md b/packages/react/README.md index e87dcf76..e4d9b557 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -20,7 +20,10 @@ import 'lenis/dist/lenis.css' ## Usage -### Basic +### Page scroll (`root`) + +Use `root` to make Lenis drive the window/page scroll. No wrapper elements are +rendered, and the instance is globally accessible via `useLenis()`. ```jsx import { ReactLenis, useLenis } from 'lenis/react' @@ -40,21 +43,91 @@ function App() { } ``` +### Scoped container + +Without `root`, `` renders its own `wrapper`/`content` divs and +scrolls *that* element instead of the page. The instance is available to +descendants via `useLenis()`. + +```jsx + + {/* scrolls inside this box */} + +``` + +### Named instances + +Give an instance a `name` to reach it from anywhere — outside its subtree, +alongside the page scroll — with `useLenis(name)`. + +```jsx +function Layout() { + return ( + <> + {/* the page */} + + {/* sidebar content */} + + + ) +} + +// anywhere in the app +function ScrollSidebarToTop() { + const sidebar = useLenis('sidebar') + return +} +``` + ## Props -- `options`: [Lenis options](https://github.com/darkroomengineering/lenis#instance-settings). -- `root`: When `true`, makes the Lenis instance globally accessible via `useLenis` from anywhere in your app (even outside the provider tree). Lenis will use the default `` scroll container. When `'asChild'`, renders wrapper elements for custom scroll containers while still making the instance globally accessible. Default: `false`. -## Hooks -Once the Lenis context is set (components mounted inside ``) you can use these handy hooks: +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `options` | [`LenisOptions`](https://github.com/darkroomengineering/lenis#instance-settings) | `{}` | Options forwarded to the Lenis instance. | +| `root` | `boolean` | `false` | When `true`, Lenis drives the window/page scroll and renders no wrapper elements. When `false`, it scrolls the wrapper/content divs it renders for you. | +| `rootContext` | `boolean` | same as `root` | Registers the instance in the global registry so `useLenis()` can reach it from anywhere (even outside the provider tree). Independent of `root` — set it on a scoped container to expose it globally, or unset it on a `root` to keep it local. | +| `name` | `string` | — | Registers the instance under a name so it can be reached anywhere via `useLenis(name)`. Use it for secondary scrollers (e.g. a sidebar) alongside the page scroll. | +| `className` | `string` | `''` | Class applied to the rendered `wrapper` div (ignored when `root`). | +| `ref` | `Ref` | — | Exposes `{ wrapper, content, lenis }`. `wrapper`/`content` are `null` when `root`. | + +> Any other props (`onClick`, `style`, …) are spread onto the rendered `wrapper` div. + +## `useLenis` + +Returns the Lenis instance and, optionally, subscribes a callback to its scroll. -`useLenis` is a hook that returns the Lenis instance +```jsx +const lenis = useLenis() // nearest provider, or the global root +const sidebar = useLenis('sidebar') // a named instance, from anywhere + +useLenis((lenis) => { + // called every scroll +}) +``` + +### Resolution -The hook takes three arguments: -- `callback`: The function to be called whenever a scroll event is emitted -- `deps`: Trigger callback on change -- `priority`: Manage callback execution order +- **No name** — uses the nearest `` (React context), falling back to + the global `root` / `rootContext` instance. +- **With a name** — targets that named instance directly, ignoring context. +### Arguments +| Arg | Type | Description | +|-----|------|-------------| +| `name` _(optional, first)_ | `string` | Target a named instance instead of the context/root. | +| `callback` | `(lenis) => void` | Called on every scroll event. Omit to just read the instance. | +| `priority` _(optional)_ | `number` | Order this callback runs in relative to other scroll callbacks (lower runs first). Default `0`. | +| `deps` | `unknown[]` | Re-subscribe the callback when one of these changes (like `useEffect`). | + +```jsx +useLenis(cb) +useLenis(cb, [dep]) +useLenis(cb, 1, [dep]) // with priority +useLenis('sidebar', cb) +useLenis('sidebar', cb, [dep]) +useLenis('sidebar', cb, 1, [dep]) // with priority +``` diff --git a/packages/react/src/provider.tsx b/packages/react/src/provider.tsx index ec798191..5de94a7f 100644 --- a/packages/react/src/provider.tsx +++ b/packages/react/src/provider.tsx @@ -10,19 +10,11 @@ import { useRef, useState, } from 'react' -import { Store } from './store' +import { getRegistryStore, ROOT_KEY } from './store' import type { LenisContextValue, LenisProps, LenisRef } from './types' export const LenisContext = createContext(null) -/** - * The root store for the lenis context - * - * This store serves as a fallback for the context if it is not available - * and allows us to use the global lenis instance above a provider - */ -export const rootLenisContextStore = new Store(null) - /** * React component to setup a Lenis instance */ @@ -33,8 +25,9 @@ export const ReactLenis: ForwardRefExoticComponent< { children, root = false, + rootContext = root, + name, options = {}, - autoRaf = true, className = '', ...props }: LenisProps, @@ -65,7 +58,7 @@ export const ReactLenis: ForwardRefExoticComponent< wrapper: wrapperRef.current!, content: contentRef.current!, }), - autoRaf: options?.autoRaf ?? autoRaf, // this is to avoid breaking the autoRaf prop if it's still used (require breaking change) + autoRaf: options?.autoRaf, }) setLenis(lenis) @@ -74,7 +67,7 @@ export const ReactLenis: ForwardRefExoticComponent< lenis.destroy() setLenis(undefined) } - }, [autoRaf, JSON.stringify({ ...options, wrapper: null, content: null })]) + }, [JSON.stringify({ ...options, wrapper: null, content: null })]) // Handle callbacks const callbacksRefs = useRef< @@ -101,14 +94,28 @@ export const ReactLenis: ForwardRefExoticComponent< [] ) - // This makes sure to set the global context if the root is true + // Publish to the named registry so useLenis() / useLenis(name) can reach + // this instance from outside its subtree. `rootContext` -> the global + // ROOT_KEY entry, `name` -> its own key; both are entries in one registry. useEffect(() => { - if (root && lenis) { - rootLenisContextStore.set({ lenis, addCallback, removeCallback }) + if (!lenis) return + + const keys: string[] = [] + if (rootContext) keys.push(ROOT_KEY) + if (name && name !== ROOT_KEY) keys.push(name) + if (keys.length === 0) return - return () => rootLenisContextStore.set(null) + const value = { lenis, addCallback, removeCallback } + for (const key of keys) { + getRegistryStore(key).set(value) + } + + return () => { + for (const key of keys) { + getRegistryStore(key).set(null) + } } - }, [root, lenis, addCallback, removeCallback]) + }, [rootContext, name, lenis, addCallback, removeCallback]) // Setup callback listeners useEffect(() => { @@ -133,7 +140,7 @@ export const ReactLenis: ForwardRefExoticComponent< - {root && root !== 'asChild' ? ( + {root ? ( children ) : (
= (state: S) => void @@ -15,24 +16,44 @@ export class Store { } } - subscribe(listener: Listener) { + subscribe = (listener: Listener) => { this.listeners = [...this.listeners, listener] return () => { this.listeners = this.listeners.filter((l) => l !== listener) } } - get() { + get = () => { return this.state } } export function useStore(store: Store) { - const [state, setState] = useState(store.get()) + return useSyncExternalStore(store.subscribe, store.get, store.get) +} - useEffect(() => { - return store.subscribe((state) => setState(state)) - }, [store]) +/** + * Reserved registry key for the global/page instance (`root` / `rootContext`). + * `useLenis()` with no name falls back to this entry. + */ +export const ROOT_KEY = 'root' + +/** + * Registry of named Lenis instances. Each key owns its own Store so that a + * `useLenis(name)` only re-renders when *that* instance changes, not when any + * other one does. The global root is simply the entry under {@link ROOT_KEY}. + */ +const registry = new Map>() + +export function getRegistryStore(name: string) { + let store = registry.get(name) + if (!store) { + store = new Store(null) + registry.set(name, store) + } + return store +} - return state +export function useRegistry(name: string) { + return useStore(getRegistryStore(name)) } diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 81090a19..2947bb4b 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -10,21 +10,29 @@ export type LenisContextValue = { export type LenisProps = ComponentPropsWithoutRef<'div'> & { /** - * Setup a global instance of Lenis - * if `asChild`, the component will render wrapper and content divs + * Target the window: render no wrapper divs and let Lenis drive the page + * scroll. When `false`, Lenis scrolls the wrapper/content divs it renders. * @default false */ - root?: boolean | 'asChild' + root?: boolean /** - * Lenis options + * Register this instance in the global store so `useLenis` can reach it from + * anywhere in the app (outside this provider's subtree). Independent of + * `root` — set it to expose a scoped container globally, or to keep a window + * scroller local. Defaults to `root`. + * @default root */ - options?: LenisOptions + rootContext?: boolean + /** + * Register this instance under a name so it can be reached from anywhere via + * `useLenis(name)`, independent of the provider subtree. Use it to expose + * secondary scrollers (e.g. a sidebar) alongside the global root. + */ + name?: string /** - * Auto-setup requestAnimationFrame - * @default true - * @deprecated use options.autoRaf instead + * Lenis options */ - autoRaf?: boolean + options?: LenisOptions /** * Children */ @@ -41,13 +49,13 @@ export type LenisRef = { /** * The wrapper div element * - * Will only be defined if `root` is `false` or `root` is `asChild` + * Will only be defined if `root` is `false` */ wrapper: HTMLDivElement | null /** * The content div element * - * Will only be defined if `root` is `false` or `root` is `asChild` + * Will only be defined if `root` is `false` */ content: HTMLDivElement | null /** diff --git a/packages/react/src/use-lenis.ts b/packages/react/src/use-lenis.ts index 99db46a2..f1fca956 100644 --- a/packages/react/src/use-lenis.ts +++ b/packages/react/src/use-lenis.ts @@ -1,64 +1,80 @@ import type Lenis from 'lenis' import type { ScrollCallback } from 'lenis' import { useContext, useEffect } from 'react' -import { LenisContext, rootLenisContextStore } from './provider' -import { useStore } from './store' +import { LenisContext } from './provider' +import { ROOT_KEY, useRegistry } from './store' import type { LenisContextValue } from './types' -// Fall back to an empty object if both context and store are not available +// Fall back to an empty object if neither context nor registry has an instance const fallbackContext: Partial = {} /** - * Hook to access the Lenis instance and its methods + * Hook to access a Lenis instance and subscribe to its scroll. * - * @example Scroll callback - * useLenis((lenis) => { - * if (lenis.isScrolling) { - * console.log('Scrolling...') - * } - * - * if (lenis.progress === 1) { - * console.log('At the end!') - * } - * }) + * Without a name it targets the nearest provider (React context) and falls + * back to the global root instance (`` / `rootContext`). + * Pass a name to reach a specific instance from anywhere in the app + * (`` → `useLenis('sidebar')`), ignoring context. * - * @example Scroll callback with dependencies - * useLenis((lenis) => { - * if (lenis.isScrolling) { - * console.log('Scrolling...', someDependency) - * } - * }, [someDependency]) - * @example Scroll callback with priority - * useLenis((lenis) => { - * if (lenis.isScrolling) { - * console.log('Scrolling...') - * } - * }, [], 1) - * @example Instance access + * @example Accessor * const lenis = useLenis() + * const sidebar = useLenis('sidebar') + * + * @example Scroll callback + * useLenis((lenis) => console.log(lenis.progress)) + * useLenis('sidebar', (lenis) => console.log(lenis.progress)) * - * handleClick() { - * lenis.scrollTo(100, { - * lerp: 0.1, - * duration: 1, - * easing: (t) => t, - * onComplete: () => { - * console.log('Complete!') - * } - * }) - * } + * @example With deps and priority + * useLenis('sidebar', (lenis) => {}, [someDependency]) + * useLenis('sidebar', (lenis) => {}, 1, [someDependency]) */ export function useLenis( callback?: ScrollCallback, - deps: unknown[] = [], - priority = 0 + deps?: unknown[] +): Lenis | undefined +export function useLenis( + callback: ScrollCallback, + priority: number, + deps?: unknown[] +): Lenis | undefined +export function useLenis( + name: string, + callback?: ScrollCallback, + deps?: unknown[] +): Lenis | undefined +export function useLenis( + name: string, + callback: ScrollCallback, + priority: number, + deps?: unknown[] +): Lenis | undefined +export function useLenis( + a?: ScrollCallback | string, + b?: ScrollCallback | number | unknown[], + c?: number | unknown[], + d?: unknown[] ): Lenis | undefined { - // Try to get the lenis instance from the context first + const named = typeof a === 'string' + + const name = named ? a : undefined + const callback = (named ? b : a) as ScrollCallback | undefined + + // The two args after the callback are (priority?, deps?) — but priority is + // skippable, so the first of them is a number when priority is present and + // an array when it's just deps. + const tail0 = named ? c : b + const tail1 = named ? d : c + const priority = typeof tail0 === 'number' ? tail0 : 0 + const deps = + ((typeof tail0 === 'number' ? tail1 : tail0) as unknown[] | undefined) ?? [] + + // Named lookups hit that registry entry directly; the default lookup prefers + // the nearest provider and falls back to the global root entry. const localContext = useContext(LenisContext) - // Fall back to the root store if the context is not available - const rootContext = useStore(rootLenisContextStore) - // Fall back to the fallback context if all else fails - const currentContext = localContext ?? rootContext ?? fallbackContext + const registryContext = useRegistry(name ?? ROOT_KEY) + const currentContext = name + ? (registryContext ?? fallbackContext) + : (localContext ?? registryContext ?? fallbackContext) const { lenis, addCallback, removeCallback } = currentContext diff --git a/packages/snap/README.md b/packages/snap/README.md index 770d4480..c6d2d246 100644 --- a/packages/snap/README.md +++ b/packages/snap/README.md @@ -52,18 +52,24 @@ npm i lenis ### Slideshow +One snap per flick, viewport-sized cards: + ```jsx const snap = new Snap(lenis, { - type: 'lock', - distanceThreshold: '100%', + mode: 'directional', // one snap per flick (direction picks the next card) + lock: true, // ignore competing gestures while a snap runs + distanceThreshold: '100%', // reach the adjacent (viewport-sized) card debounce: 0, }) ``` ## Options -- `type`: `proximity` (default), `mandatory` see [scroll-snap-type](https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-snap-type) or `lock`. -- `distanceThreshold`: `string | number` (default: '50%'). The distance threshold from the snap point to the scroll position. Ignored when `type` is `mandatory`. If a percentage, it is relative to the viewport size. If a number, it is absolute. +- `mode`: `'closest' | 'directional'` (default: `'closest'`). How a gesture maps to a snap target. + - `'closest'`: predict the post-gesture scroll position and snap to the nearest target within `distanceThreshold` (velocity-aware). + - `'directional'`: gesture *direction* picks the halfspace; the snap closest to the current scroll position whose offset is within `distanceThreshold` wins (gesture *magnitude* is ignored). For viewport-sized cards, raise `distanceThreshold` to `'100%'` or higher so the adjacent snap is reachable. Pair with `lock: true` and `debounce: 0` for the tightest one-card-per-flick feel. +- `lock`: `boolean` (default: `false`). Lock Lenis to the snap target while the animation runs — user gestures can't interrupt it, and (in `'directional'` mode) competing flicks are ignored until it settles. +- `distanceThreshold`: `string | number | [x, y]` (default: `'50%'`). Per-axis "max reach" — applied to the *predicted* position in `'closest'` mode, to the *current* position in `'directional'` mode. Percentages resolve against the viewport (per axis). Pass `Infinity` to disable the gate entirely (always snap to the nearest target). - `debounce`: `number` (default: 500). The debounce time for the snap. - `onSnapStart`: `function`. Callback when snap starts. - `onSnapComplete`: `function`. Callback when snap completes. @@ -74,9 +80,9 @@ npm i lenis ## Methods -- `add(value: number)`: Add a snap point. -- `addElement(element: HTMLElement, options: SnapElementOptions = {})`: Add an element to snap to. -- `addElements(elements: HTMLElement[], options: SnapElementOptions = {})`: Add elements at once. +- `add(x: number, y?: number)`: Add a snap point. One argument anchors the active axis (`{ y: x }`, or `{ x }` when the parent Lenis is horizontal); two arguments make a 2D point `{ x, y }`. +- `addElement(element: HTMLElement, options?: SnapElementOptions)`: Add an element to snap to. `options.align` controls where the element lands: `'start' | 'center' | 'end'` applied to both axes, or a tuple `[xAlign, yAlign]` for per-axis alignment (e.g. `['start', 'end']`). +- `addElements(elements: HTMLElement[], options?: SnapElementOptions)`: Add elements at once (same `options` as `addElement`). - `next()`: Go to the next snap point. - `previous()`: Go to the previous snap point. - `goTo(index: number)`: Go to a specific snap point. diff --git a/packages/snap/src/element.ts b/packages/snap/src/element.ts index 9d3ad7b4..08169ae7 100644 --- a/packages/snap/src/element.ts +++ b/packages/snap/src/element.ts @@ -1,4 +1,5 @@ import { debounce } from './debounce' +import type { SnapAlign } from './types' function removeParentSticky(element: HTMLElement) { const position = getComputedStyle(element).position @@ -58,8 +59,18 @@ function scrollLeft(element: HTMLElement, accumulator = 0) { return left + window.scrollX } +/** + * Each element produces a single 2D snap target. The `align` option controls + * how that target is anchored on each axis: + * + * align: 'center' // both axes centered + * align: ['start'] // both axes start (shorthand) + * align: ['start', 'end'] // x = start, y = end + * + * Extra entries are ignored; missing entries fall back to the first. + */ export type SnapElementOptions = { - align?: string | string[] + align?: SnapAlign | SnapAlign[] ignoreSticky?: boolean ignoreTransform?: boolean } @@ -79,7 +90,8 @@ type Rect = { export class SnapElement { element: HTMLElement options: SnapElementOptions - align: string[] + /** [xAlign, yAlign] — both always defined. */ + align: [SnapAlign, SnapAlign] // @ts-expect-error rect: Rect = {} wrapperResizeObserver: ResizeObserver @@ -89,16 +101,18 @@ export class SnapElement { constructor( element: HTMLElement, { - align = ['start'], + align = 'start', ignoreSticky = true, ignoreTransform = false, }: SnapElementOptions = {} ) { this.element = element - this.options = { align, ignoreSticky, ignoreTransform } - this.align = [align].flat() + const list = Array.isArray(align) ? align : [align] + const xAlign = (list[0] ?? 'start') as SnapAlign + const yAlign = (list[1] ?? list[0] ?? 'start') as SnapAlign + this.align = [xAlign, yAlign] this.debouncedWrapperResize = debounce(this.onWrapperResize, 500) diff --git a/packages/snap/src/snap.ts b/packages/snap/src/snap.ts index 45b9d7d2..339092d9 100644 --- a/packages/snap/src/snap.ts +++ b/packages/snap/src/snap.ts @@ -1,5 +1,5 @@ import type Lenis from 'lenis' -import type { VirtualScrollData } from 'lenis' +import type { GestureData } from 'lenis' import { debounce } from './debounce' import type { SnapElementOptions } from './element' import { SnapElement } from './element' @@ -14,47 +14,62 @@ import { uid } from './uid' type RequiredPick = Omit & Required> /** - * Snap class to handle the snap functionality + * Snap class. Every snap target is a 2D point `{ x?, y? }` — `undefined` + * coordinates are left untouched when scrolling, so the same shape covers 1D + * (`orientation: 'vertical' | 'horizontal'`) and 2D (`orientation: 'both'`). + * + * Detection is axis-agnostic: each gesture predicts the next 2D scroll + * position and snaps to the closest target by Euclidean distance, no matter + * whether the gesture was horizontal or vertical. * * @example - * const snap = new Snap(lenis, { - * type: 'mandatory', // 'mandatory', 'proximity' or 'lock' - * onSnapStart: (snap) => { - * console.log('onSnapStart', snap) - * }, - * onSnapComplete: (snap) => { - * console.log('onSnapComplete', snap) - * }, - * }) + * const snap = new Snap(lenis, { distanceThreshold: Infinity }) * - * snap.add(500) // snap at 500px + * // 1D: single coordinate, picked on the active axis + * snap.add(500) * - * const removeSnap = snap.add(500) + * // 2D: explicit point + * snap.add(500, 800) * - * if (someCondition) { - * removeSnap() - * } + * // Element-driven: align[0] = xAlign, align[1] = yAlign + * snap.addElement(section, { align: ['start', 'end'] }) */ export class Snap { - options: RequiredPick + options: RequiredPick elements = new Map() snaps = new Map() - viewport: { width: number; height: number } = { - width: window.innerWidth, - height: window.innerHeight, - } isStopped = false - onSnapDebounced: (e: VirtualScrollData) => void + onSnapDebounced: (e: GestureData) => void currentSnapIndex?: number + /** + * Count of snap operations currently in flight. `scrollTo` fires `onStart` / + * `onComplete` once per call (even for a 2D `{ x, y }` snap), so this is a + * clean "is snapping" gate: incremented on start, decremented on complete. + */ + private inFlight = 0 + + /** + * Wrapper dimensions. Reads directly from the parent Lenis instance which + * keeps these in sync with a ResizeObserver on the wrapper element — so a + * non-window wrapper (e.g. a scrollable div) reports its own client size, + * not the window's. + */ + get viewport(): { width: number; height: number } { + return { + width: this.lenis.dimensions.width!, + height: this.lenis.dimensions.height!, + } + } constructor( private lenis: Lenis, { - type = 'proximity', + mode = 'closest', lerp, + lock = false, easing, duration, - distanceThreshold = '50%', // useless when type is "mandatory" + distanceThreshold = '50%', debounce: debounceDelay = 500, onSnapStart, onSnapComplete, @@ -67,73 +82,69 @@ export class Snap { window.lenis.snap = true this.options = { - type, + mode, lerp, easing, duration, + lock, distanceThreshold, debounce: debounceDelay, onSnapStart, onSnapComplete, } - this.onWindowResize() - window.addEventListener('resize', this.onWindowResize) - this.onSnapDebounced = debounce( this.onSnap as (...args: unknown[]) => void, this.options.debounce ) - this.lenis.on('virtual-scroll', this.onSnapDebounced) + this.lenis.on('gesture', this.onSnapDebounced) } - /** - * Destroy the snap instance - */ destroy() { - this.lenis.off('virtual-scroll', this.onSnapDebounced) - window.removeEventListener('resize', this.onWindowResize) + this.lenis.off('gesture', this.onSnapDebounced) this.elements.forEach((element) => { element.destroy() }) } - /** - * Start the snap after it has been stopped - */ start() { this.isStopped = false } - /** - * Stop the snap - */ stop() { this.isStopped = true } /** - * Add a snap to the snap instance + * Add a raw snap point. * - * @param value The value to snap to - * @param userData User data that will be forwarded through the snap event - * @returns Unsubscribe function + * Two-argument form is a 2D point; one-argument form anchors on the active + * axis (vertical unless the parent Lenis is horizontal). + * + * @example + * snap.add(500) // 1D: { y: 500 } (or { x: 500 } if horizontal) + * snap.add(500, 800) // 2D: { x: 500, y: 800 } */ - add(value: number): () => void { + add(x: number, y?: number): () => void { const id = uid() - - this.snaps.set(id, { value }) - + const item: SnapItem = + y === undefined + ? this.lenis.options.orientation === 'horizontal' + ? { x } + : { y: x } + : { x, y } + this.snaps.set(id, item) return () => this.snaps.delete(id) } /** - * Add an element to the snap instance + * Add an element. The element produces a single 2D snap target whose + * coordinates are derived from its rect and the `align` option. * - * @param element The element to add - * @param options The options for the element - * @returns Unsubscribe function + * `align` accepts: + * - a single value applied to both axes: `'center'`, `['start']` + * - a tuple `[xAlign, yAlign]`: `['start', 'end']` */ addElement( element: HTMLElement, @@ -147,10 +158,10 @@ export class Snap { } addElements( - elements: HTMLElement[], + elements: HTMLElement[] | NodeListOf, options: SnapElementOptions = {} ): () => void { - const map = [...elements].map((element) => + const map = Array.from(elements).map((element) => this.addElement(element, options) ) return () => { @@ -160,40 +171,68 @@ export class Snap { } } - private onWindowResize = () => { - this.viewport.width = window.innerWidth - this.viewport.height = window.innerHeight - } + /** + * Compute every 2D snap target. Elements contribute one point each (their + * `align`-resolved coordinates); raw `snap.add` items pass through as-is. + * Identical points are deduped so the cursor / proximity math sees a clean + * sequence even when many elements share the same column/row. + */ + private computeSnaps = (): SnapItem[] => { + const horizontalOnly = this.lenis.options.orientation === 'horizontal' + const isTwoAxis = this.lenis.options.orientation === 'both' - private computeSnaps = () => { - const { isHorizontal } = this.lenis + const collected: SnapItem[] = [] - let snaps = [...this.snaps.values()] as SnapItem[] + for (const snap of this.snaps.values()) { + collected.push(snap) + } this.elements.forEach(({ rect, align }) => { - let value: number | undefined - - align.forEach((align) => { - if (align === 'start') { - value = rect.top - } else if (align === 'center') { - value = isHorizontal - ? rect.left + rect.width / 2 - this.viewport.width / 2 - : rect.top + rect.height / 2 - this.viewport.height / 2 - } else if (align === 'end') { - value = isHorizontal - ? rect.left + rect.width - this.viewport.width - : rect.top + rect.height - this.viewport.height - } - - if (typeof value === 'number') { - snaps.push({ value: Math.ceil(value) }) - } - }) + const [xAlign, yAlign] = align + + const resolveX = () => { + if (xAlign === 'start') return rect.left + if (xAlign === 'center') + return rect.left + rect.width / 2 - this.viewport.width / 2 + return rect.left + rect.width - this.viewport.width + } + const resolveY = () => { + if (yAlign === 'start') return rect.top + if (yAlign === 'center') + return rect.top + rect.height / 2 - this.viewport.height / 2 + return rect.top + rect.height - this.viewport.height + } + + // In 1D mode only emit the active axis coord; in 2D emit both. + let item: SnapItem + if (isTwoAxis) { + item = { x: Math.ceil(resolveX()), y: Math.ceil(resolveY()) } + } else if (horizontalOnly) { + item = { x: Math.ceil(resolveX()) } + } else { + item = { y: Math.ceil(resolveY()) } + } + collected.push(item) }) - snaps = snaps.sort((a, b) => Math.abs(a.value) - Math.abs(b.value)) + // Sort by (x, y) lexicographically — gives `next/previous` a stable order + // and lets us dedupe consecutive identical points. + collected.sort((a, b) => { + const ax = a.x ?? Number.NEGATIVE_INFINITY + const bx = b.x ?? Number.NEGATIVE_INFINITY + if (ax !== bx) return ax - bx + const ay = a.y ?? Number.NEGATIVE_INFINITY + const by = b.y ?? Number.NEGATIVE_INFINITY + return ay - by + }) + const snaps: SnapItem[] = [] + for (const item of collected) { + const last = snaps[snaps.length - 1] + if (!last || last.x !== item.x || last.y !== item.y) { + snaps.push(item) + } + } return snaps } @@ -207,115 +246,180 @@ export class Snap { goTo(index: number) { const snaps = this.computeSnaps() - if (snaps.length === 0) return - this.currentSnapIndex = Math.max(0, Math.min(index, snaps.length - 1)) + const clamped = Math.max(0, Math.min(index, snaps.length - 1)) + this.currentSnapIndex = clamped - const currentSnap = snaps[this.currentSnapIndex] + const currentSnap = snaps[clamped] if (currentSnap === undefined) return - this.lenis.scrollTo(currentSnap.value, { + const target: { x?: number; y?: number } = {} + if (currentSnap.x !== undefined) target.x = currentSnap.x + if (currentSnap.y !== undefined) target.y = currentSnap.y + + // `scrollTo` runs the 2D target as one operation, firing onStart/onComplete + // once for the whole snap — so onSnapStart/onSnapComplete fire once each. + this.lenis.scrollTo(target, { duration: this.options.duration, easing: this.options.easing, lerp: this.options.lerp, - lock: this.options.type === 'lock', - userData: { initiator: 'snap' }, + lock: this.options.lock, onStart: () => { + this.inFlight++ this.options.onSnapStart?.({ - index: this.currentSnapIndex, + index: clamped, ...currentSnap, }) }, onComplete: () => { + this.inFlight = Math.max(0, this.inFlight - 1) this.options.onSnapComplete?.({ - index: this.currentSnapIndex, + index: clamped, ...currentSnap, }) }, }) } - get distanceThreshold() { - let distanceThreshold = Number.POSITIVE_INFINITY - if (this.options.type === 'mandatory') return Number.POSITIVE_INFINITY - - const { isHorizontal } = this.lenis - - const axis = isHorizontal ? 'width' : 'height' - - if ( - typeof this.options.distanceThreshold === 'string' && - this.options.distanceThreshold.endsWith('%') - ) { - distanceThreshold = - (Number(this.options.distanceThreshold.replace('%', '')) / 100) * - this.viewport[axis] - } else if (typeof this.options.distanceThreshold === 'number') { - distanceThreshold = this.options.distanceThreshold - } else { - distanceThreshold = this.viewport[axis] + /** + * Resolve a single threshold entry against a base dimension. Percentages + * scale against `base`; numbers pass through as pixels. + */ + private resolveThresholdValue( + value: number | `${number}%` | undefined, + base: number + ): number { + if (typeof value === 'string' && value.endsWith('%')) { + return (Number(value.replace('%', '')) / 100) * base } + if (typeof value === 'number') return value + return base + } - return distanceThreshold + /** + * Threshold expressed as per-axis pixel values. Scalar / percentage inputs + * resolve against each axis's viewport dimension independently. Pass + * `Infinity` (per axis or scalar) to disable the gate entirely. + */ + private get resolvedThreshold(): { x: number; y: number } { + const { distanceThreshold } = this.options + const [xRaw, yRaw] = Array.isArray(distanceThreshold) + ? distanceThreshold + : [distanceThreshold, distanceThreshold] + + return { + x: this.resolveThresholdValue(xRaw, this.viewport.width), + y: this.resolveThresholdValue(yRaw, this.viewport.height), + } } - private onSnap = (e: VirtualScrollData) => { + private onSnap = (e: GestureData) => { if (this.isStopped) return - if (e.event.type === 'touchmove') return - - if ( - this.options.type === 'lock' && - this.lenis.userData?.initiator === 'snap' - ) - return - - let { scroll, isHorizontal } = this.lenis - const delta = isHorizontal ? e.deltaX : e.deltaY - scroll = Math.ceil(this.lenis.scroll + delta) + // `lock: true` ⇒ ignore gestures while a snap animation is still running, + // so a flick mid-snap can't kick off a competing snap. + if (this.options.lock === true && this.inFlight > 0) return const snaps = this.computeSnaps() - if (snaps.length === 0) return - let snapIndex: number | undefined + const threshold = this.resolvedThreshold + const bestIndex = + this.options.mode === 'directional' + ? this.pickDirectional(snaps, e, threshold) + : this.pickClosest(snaps, e, threshold) - const prevSnapIndex = snaps.findLastIndex(({ value }) => value < scroll) - const nextSnapIndex = snaps.findIndex(({ value }) => value > scroll) + if (bestIndex === -1) return + this.goTo(bestIndex) + } - if (this.options.type === 'lock') { - if (delta > 0) { - snapIndex = nextSnapIndex - } else if (delta < 0) { - snapIndex = prevSnapIndex - } - } else { - const prevSnap = snaps[prevSnapIndex]! - const distanceToPrevSnap = prevSnap - ? Math.abs(scroll - prevSnap.value) - : Number.POSITIVE_INFINITY - - const nextSnap = snaps[nextSnapIndex]! - const distanceToNextSnap = nextSnap - ? Math.abs(scroll - nextSnap.value) - : Number.POSITIVE_INFINITY - snapIndex = - distanceToPrevSnap < distanceToNextSnap ? prevSnapIndex : nextSnapIndex + /** + * Predict the post-gesture 2D scroll position and pick the snap closest to + * it. Per-axis threshold gates the nearest-neighbour search so a snap can't + * win just by being close on one axis. + */ + private pickClosest( + snaps: SnapItem[], + e: GestureData, + threshold: { x: number; y: number } + ): number { + // The gesture event fires before per-axis routing in core, so adding the + // gesture delta to the current scroll mirrors what Lenis is about to do. + const predicted = { + x: Math.ceil(this.lenis.x.scroll + e.deltaX), + y: Math.ceil(this.lenis.y.scroll + e.deltaY), } - if (snapIndex === undefined) return - if (snapIndex === -1) return + let bestIndex = -1 + let bestDistance = Number.POSITIVE_INFINITY + for (let i = 0; i < snaps.length; i++) { + const snap = snaps[i]! + const dx = snap.x === undefined ? 0 : snap.x - predicted.x + const dy = snap.y === undefined ? 0 : snap.y - predicted.y - snapIndex = Math.max(0, Math.min(snapIndex, snaps.length - 1)) + if (snap.x !== undefined && Math.abs(dx) > threshold.x) continue + if (snap.y !== undefined && Math.abs(dy) > threshold.y) continue - const snap = snaps[snapIndex]! + const distance = Math.hypot(dx, dy) + if (distance < bestDistance) { + bestDistance = distance + bestIndex = i + } + } + return bestIndex + } - const distance = Math.abs(scroll - snap.value) + /** + * Slideshow / carousel selection. The gesture's *direction* (per axis) + * picks the halfspace; we then return the snap closest to the current + * scroll position in that halfspace whose per-axis offset is within + * `distanceThreshold`. The gesture *magnitude* is irrelevant — every + * directional flick advances by one snap as long as a reachable + * candidate exists. + */ + private pickDirectional( + snaps: SnapItem[], + e: GestureData, + threshold: { x: number; y: number } + ): number { + const dirX = Math.sign(e.deltaX) as -1 | 0 | 1 + const dirY = Math.sign(e.deltaY) as -1 | 0 | 1 + if (dirX === 0 && dirY === 0) return -1 + + const current = { + x: this.lenis.x.scroll, + y: this.lenis.y.scroll, + } - if (distance <= this.distanceThreshold) { - this.goTo(snapIndex) + let bestIndex = -1 + let bestDistance = Number.POSITIVE_INFINITY + for (let i = 0; i < snaps.length; i++) { + const snap = snaps[i]! + const dx = snap.x === undefined ? 0 : snap.x - current.x + const dy = snap.y === undefined ? 0 : snap.y - current.y + + // Skip the snap we're already on (within sub-pixel rounding). + if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue + + // Direction gate: each gesture-active axis with a defined snap coord + // must lie in the gesture's halfspace. + if (dirX !== 0 && snap.x !== undefined && Math.sign(dx) !== dirX) continue + if (dirY !== 0 && snap.y !== undefined && Math.sign(dy) !== dirY) continue + + // Reach gate: snap must sit within `distanceThreshold` of the current + // scroll on each axis with a defined coord. Acts as a "max jump" so + // we don't leap past plausible neighbours into a far-off target. + if (snap.x !== undefined && Math.abs(dx) > threshold.x) continue + if (snap.y !== undefined && Math.abs(dy) > threshold.y) continue + + const distance = Math.hypot(dx, dy) + if (distance < bestDistance) { + bestDistance = distance + bestIndex = i + } } + return bestIndex } resize() { diff --git a/packages/snap/src/types.ts b/packages/snap/src/types.ts index 76ff604e..44ade476 100644 --- a/packages/snap/src/types.ts +++ b/packages/snap/src/types.ts @@ -1,17 +1,40 @@ import type { EasingFunction } from 'lenis' +export type SnapAlign = 'start' | 'center' | 'end' + +/** + * A 2D snap target. `x` and `y` are optional so 1D snaps (single axis) and 2D + * snaps (`orientation: 'both'`) can share the same shape — an undefined + * coordinate is left untouched when scrolling. + */ export type SnapItem = { - value: number + x?: number + y?: number } export type OnSnapCallback = (item: SnapItem & { index?: number }) => void export type SnapOptions = { /** - * Snap type - * @default 'proximity' + * @description Whether to lock the scroll on the snap + * @default false + */ + lock?: boolean + /** + * @description How a gesture is mapped to a snap target. + * - `'closest'` — predict the post-gesture scroll position from the + * gesture delta and snap to the nearest target within + * `distanceThreshold` (velocity-aware). + * - `'directional'` — the gesture *direction* picks the halfspace; we then + * pick the snap closest to the current scroll position whose per-axis + * offset is within `distanceThreshold` (gesture *magnitude* is + * ignored — every directional flick advances by one snap if a + * reachable candidate exists). Pair with `lock: true` and + * `debounce: 0` for the tightest one-card-per-flick feel. + * + * @default 'closest' */ - type?: 'mandatory' | 'proximity' | 'lock' + mode?: 'closest' | 'directional' /** * @description Linear interpolation (lerp) intensity (between 0 and 1) */ @@ -26,9 +49,30 @@ export type SnapOptions = { duration?: number /** * @default '50%' - * @description The distance threshold from the snap point to the scroll position. Ignored when `type` is `mandatory`. If a percentage, it is relative to the viewport size. If a number, it is absolute. + * @description Per-axis "max reach" applied as `|snap - reference| ≤ value`, + * where the reference depends on `mode`: + * - `mode: 'closest'` — reference is the *predicted* post-gesture scroll + * position. Pass `Infinity` for "always snap to the nearest" (the + * former `type: 'mandatory'` behavior). + * - `mode: 'directional'` — reference is the *current* scroll position. + * Acts as a "max jump" so we don't leap over plausible neighbours. + * For viewport-sized cards, set this to `'100%'` (or higher) so the + * adjacent snap is reachable. + * + * Shape: + * - Scalar (`number` or `'50%'`): applied to both axes. Percentages scale + * against each axis's viewport dimension independently (`x` → width, + * `y` → height), so `'50%'` is "half a viewport on each axis". + * - Tuple `[x, y]`: separate value per axis. Each entry follows the same + * number-or-percentage rule. + * + * Coordinates left `undefined` on a snap item skip their axis check + * (always pass). */ - distanceThreshold?: number | `${number}%` + distanceThreshold?: + | number + | `${number}%` + | [number | `${number}%`, number | `${number}%`] /** * @default 500 * @description The debounce delay (in ms) to prevent snapping too often. diff --git a/playground/core/test.ts b/playground/core/test.ts index 41b4ded4..ccdf49ad 100644 --- a/playground/core/test.ts +++ b/playground/core/test.ts @@ -43,9 +43,9 @@ const lenis = new Lenis({ smooth: true, // duration: 5, }, - dimensions: { - mode: 'read', - }, + // dimensions: { + // mode: 'read', + // }, onGesture: (data, lenis) => { // console.log(data) // return { @@ -67,7 +67,12 @@ const lenis = new Lenis({ // }) lenis.on('scroll', (lenis) => { - console.log(lenis.isScrolling, lenis.isTouch, lenis.isWheel) + console.log({ + userData: lenis.userData, + scroll: lenis.scroll, + actualScroll: lenis.actualScroll, + targetScroll: lenis.targetScroll, + }) // console.log('scroll', e) }) @@ -160,6 +165,9 @@ document.getElementById('start')?.addEventListener('click', () => { document.getElementById('scroll-start')?.addEventListener('click', () => { lenis.scrollTo(100, { + userData: { + test: 'test', + }, lock: true, duration: 1, onComplete: () => { diff --git a/playground/react/app.tsx b/playground/react/app.tsx index a31fbc60..d6f8676f 100644 --- a/playground/react/app.tsx +++ b/playground/react/app.tsx @@ -1,45 +1,78 @@ -import { type LenisRef, ReactLenis, useLenis } from 'lenis/react' +import 'lenis/dist/lenis.css' +import { ReactLenis, useLenis } from 'lenis/react' import { LoremIpsum } from 'lorem-ipsum' -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' + +const lorem = new LoremIpsum() function App() { - const [lorem] = useState(() => new LoremIpsum().generateParagraphs(200)) - const [count, setCount] = useState(0) + const [pageText] = useState(() => lorem.generateParagraphs(40)) + const [sidebarText] = useState(() => lorem.generateParagraphs(30)) + + return ( + <> + {/* Page scroll (window). `root` => no wrapper divs, globally reachable + via useLenis() */} + + + {/* A named, scoped scroll container — its own wrapper divs, reachable + anywhere via useLenis('sidebar') */} + +

Sidebar (name="sidebar")

+ {sidebarText} +
- useLenis() + {/* Sibling of both providers — proves cross-subtree access works */} + + +
+

Page (root)

+ {pageText} +
+ + ) +} - const lenisRef = useRef(null) +function Controls() { + const [page, setPage] = useState(0) + const [sidebar, setSidebar] = useState(0) - useEffect(() => { - console.log('lenisRef', lenisRef.current) - }, []) + // root instance (no name): subscribe to the window scroll + const lenis = useLenis((l) => setPage(l.progress)) + // named instance, from outside its subtree: subscribe to the sidebar scroll + const sidebarLenis = useLenis('sidebar', (l) => setSidebar(l.progress)) return ( - -
- -

- DOM className: -

- -

- Scroll, then click the button. Lenis classes should persist. -

+ +
+
+ sidebar + + + +
+
+ found + + root: {lenis ? 'yes' : 'no'} · sidebar:{' '} + {sidebarLenis ? 'yes' : 'no'} +
- {lorem} -
+
) } -// Poll DOM className outside React to avoid extra renders -setInterval(() => { - const wrapper = document.querySelector('.lenis') - const display = document.getElementById('class-display') - if (wrapper && display) { - display.textContent = wrapper.className - } -}, 100) - export default App diff --git a/playground/react/style.css b/playground/react/style.css index 5389d675..97784616 100644 --- a/playground/react/style.css +++ b/playground/react/style.css @@ -1,80 +1,94 @@ -html:not(.lenis) { - body, - & { - margin: 0; - padding: 0; - width: 100%; - height: 100%; - overflow: hidden; - } - body { - position: fixed; - overscroll-behavior-y: none; - overscroll-behavior-x: none; - } +* { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: system-ui, sans-serif; + background: #0b0b0f; + color: #e6e6ea; + line-height: 1.6; +} + +.page { + max-width: 680px; + margin: 0 auto; + padding: 40px 24px 160px; +} + +.page h1 { + position: sticky; + top: 0; + margin: 0 0 24px; + padding: 12px 0; + background: #0b0b0f; +} - .lenis { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - overflow: hidden; - overflow-y: scroll; - -ms-scroll-chaining: none; - overscroll-behavior: contain; - background: #0b41cd; - } +/* Scoped scroll container — Lenis observes the overflow we set here */ +.sidebar { + position: fixed; + top: 16px; + right: 16px; + bottom: 16px; + width: 320px; + padding: 0 16px; + background: #15151c; + border: 1px solid #2a2a35; + border-radius: 10px; + overflow-y: auto; } -.wrapper.lenis-scrolling { - background: #1a6b2a; +.sidebar h2 { + position: sticky; + top: 0; + margin: 0; + padding: 16px 0 12px; + background: #15151c; + font-size: 15px; } -.debug-panel { +.controls { position: fixed; - bottom: 12px; - right: 12px; + left: 16px; + bottom: 16px; z-index: 100; + display: grid; + gap: 10px; + padding: 14px 16px; background: rgba(0, 0, 0, 0.85); - color: #fff; - padding: 16px; - border-radius: 8px; - font-family: monospace; - font-size: 13px; - max-width: 400px; + border-radius: 10px; + font: 13px monospace; } -.debug-panel button { - padding: 8px 16px; - font-size: 14px; - cursor: pointer; - font-family: monospace; - border: 1px solid #555; - background: #222; - color: #fff; - border-radius: 4px; +.controls .row { + display: flex; + align-items: center; + gap: 8px; } -.debug-panel button:hover { - background: #444; +.controls .row span { + width: 56px; + color: #9a9aa6; } -.debug-panel code { - display: block; - padding: 6px 8px; - background: #111; - border-radius: 4px; - word-break: break-all; +.controls progress { + width: 140px; +} + +.controls code { color: #4fc3f7; } -.debug-panel p { - margin: 8px 0 4px; +.controls button { + padding: 4px 10px; + cursor: pointer; + background: #222; + color: #fff; + border: 1px solid #555; + border-radius: 4px; + font: 12px monospace; } -.debug-panel .hint { - color: #999; - font-size: 11px; - margin-top: 10px; +.controls button:hover { + background: #444; } diff --git a/playground/snap/test.ts b/playground/snap/test.ts index db7a71ff..74d583fc 100644 --- a/playground/snap/test.ts +++ b/playground/snap/test.ts @@ -18,10 +18,16 @@ const lenis = new Lenis({ const _i = 0 const snap = new Snap(lenis, { - type: 'lock', // 'mandatory', 'proximity', 'lock' + // lock: true, // velocityThreshold: 1.2, duration: 1, - distanceThreshold: '50%', + // Directional gates by `|snap - currentScroll| ≤ distanceThreshold`. The + // sections in this playground are 50–250vh, so adjacent snaps can sit + // 2+ viewports apart — `Infinity` disables the gate so any flick + // reaches the next snap. Lower to e.g. `'100%'` to see the gate clip + // far jumps. + distanceThreshold: Number.POSITIVE_INFINITY, + mode: 'directional', debounce: 500, // duration: 2, // easing: (t) => t, @@ -93,9 +99,7 @@ const _unsubs = snap.addElements([section4, section5], { // align: ['start', 'end'], // 'start', 'center', 'end' // }) -function raf(time: number) { - lenis.raf(time) - requestAnimationFrame(raf) -} - -requestAnimationFrame(raf) +// Lenis defaults to `autoRaf: true` and runs its own RAF loop, so no manual +// `requestAnimationFrame(raf)` is needed here. Pass `autoRaf: false` to the +// Lenis constructor and re-add a manual loop if you want to drive ticks +// from an external clock (e.g. Tempus). diff --git a/playground/tsconfig.json b/playground/tsconfig.json index 68415cd2..df1d77e8 100644 --- a/playground/tsconfig.json +++ b/playground/tsconfig.json @@ -5,7 +5,11 @@ "jsxImportSource": "react", "baseUrl": ".", "paths": { - "~/*": ["./*"] + "~/*": ["./*"], + "lenis": ["../dist/lenis.d.ts"], + "lenis/react": ["../dist/lenis-react.d.ts"], + "lenis/snap": ["../dist/lenis-snap.d.ts"], + "lenis/vue": ["../dist/lenis-vue.d.ts"] } } } diff --git a/playground/two-axis/static.html b/playground/two-axis/static.html index 280f6b1d..47b063be 100644 --- a/playground/two-axis/static.html +++ b/playground/two-axis/static.html @@ -1,6 +1,7 @@ +
@@ -18,6 +19,10 @@ for (let y = 0; y < rows; y++) { for (let x = 0; x < cols; x++) { const cell = document.createElement('div') + const innerCell = document.createElement('div') + innerCell.className = 'inner-cell' + innerCell.textContent = `${x},${y}` + cell.appendChild(innerCell) cell.className = 'cell' const edge = edgeFor(x, y) if (edge) cell.dataset.edge = edge @@ -27,5 +32,33 @@ }
+ + diff --git a/playground/two-axis/style.css b/playground/two-axis/style.css index b9dbf912..f446ca60 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 { @@ -49,4 +49,136 @@ 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; +} + +#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); +} + +@media (min-width: 768px) { + #tweak { + display: none; + } +} \ No newline at end of file diff --git a/playground/two-axis/test.ts b/playground/two-axis/test.ts index 2be4a3d0..c76e6e33 100644 --- a/playground/two-axis/test.ts +++ b/playground/two-axis/test.ts @@ -1,9 +1,132 @@ import Lenis from 'lenis' +import Snap from 'lenis/snap' -const lenis = new Lenis({}) +const lenis = new Lenis({ + wrapper: document.querySelector('#grid')!, + orientation: 'both', + infinite: true, + touch: { + smooth: true, + // ios: { + // smooth: true, + // }, + }, + wheel: { + smooth: true, + }, + // onGesture: (data) => { + // console.log(data.type, data.deltaX, data.deltaY) + // }, +}) + +const snap = new Snap(lenis, { + distanceThreshold: Number.POSITIVE_INFINITY, // former `type: 'mandatory'` + mode: 'directional', // one snap per flick (former `type: 'lock'`) + // lock: true, + debounce: 500, + onSnapComplete: (data) => { + console.log('complete', data) + }, + onSnapStart: (data) => { + console.log('start', data) + }, +}) + +// // Each cell of the 5×5 grid is one viewport (100vw × 100svh). Each cell +// // becomes a single 2D snap target at its top-left corner — `align: 'start'` +// // applies to both axes. +const cells = document.querySelectorAll('#grid .inner-cell') +snap.addElements(cells, { align: ['center', 'center'] }) + +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, Number.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(lenis.isScrolling, lenis.isTouch, lenis.isWheel) + console.log(lenis, { + scroll: lenis.y.scroll, + // rounded: Math.round(lenis.x.scroll), + actuallScroll: lenis.y.actualScroll, + }) }) window.lenis = lenis +// ;(window as unknown as { snap: Snap }).snap = snap + +// const wrapper = document.querySelector('#grid')! + +// wrapper.addEventListener('wheel', (e) => { +// // e.preventDefault() +// console.log('wheel', e.deltaX, e.deltaY) +// }) diff --git a/playground/www/pages/two-axis.astro b/playground/www/pages/two-axis.astro index 17d4c1cb..5b2053e9 100644 --- a/playground/www/pages/two-axis.astro +++ b/playground/www/pages/two-axis.astro @@ -14,19 +14,55 @@ 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 }) => (
- {x},{y} +
{x},{y}
+
)) }
+ + + + +