scroll-snap-kit 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,6 +4,10 @@
4
4
 
5
5
  Zero dependencies. Tree-shakeable. Works with or without React.
6
6
 
7
+ [![npm version](https://img.shields.io/npm/v/scroll-snap-kit)](https://www.npmjs.com/package/scroll-snap-kit)
8
+ [![license](https://img.shields.io/npm/l/scroll-snap-kit)](./LICENSE)
9
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/scroll-snap-kit)](https://bundlephobia.com/package/scroll-snap-kit)
10
+
7
11
  ---
8
12
 
9
13
  ## Install
@@ -14,27 +18,60 @@ npm install scroll-snap-kit
14
18
 
15
19
  ---
16
20
 
21
+ ## What's included
22
+
23
+ | Utility | Description |
24
+ |---------|-------------|
25
+ | `scrollTo` | Smooth scroll to an element or pixel value |
26
+ | `scrollToTop` / `scrollToBottom` | Page-level scroll helpers |
27
+ | `getScrollPosition` | Current scroll x/y + percentages |
28
+ | `onScroll` | Throttled scroll listener with cleanup |
29
+ | `isInViewport` | Check if an element is visible |
30
+ | `lockScroll` / `unlockScroll` | Freeze body scroll, restore position |
31
+ | `scrollSpy` | Highlight nav links based on active section |
32
+ | `onScrollEnd` | Fire a callback when scrolling stops |
33
+ | `scrollIntoViewIfNeeded` | Only scroll if element is off-screen |
34
+ | `easeScroll` + `Easings` | Custom easing curves for scroll animation |
35
+
36
+ | Hook | Description |
37
+ |------|-------------|
38
+ | `useScrollPosition` | Live scroll position + percentage |
39
+ | `useInViewport` | Whether a ref'd element is visible |
40
+ | `useScrollTo` | Scroll function scoped to a container ref |
41
+ | `useScrolledPast` | Boolean — has user scrolled past a threshold |
42
+ | `useScrollDirection` | `'up'` \| `'down'` \| `null` |
43
+
44
+ ---
45
+
17
46
  ## Vanilla JS Utilities
18
47
 
19
48
  ```js
20
- import { scrollTo, scrollToTop, scrollToBottom, getScrollPosition, onScroll, isInViewport, lockScroll, unlockScroll } from 'scroll-snap-kit/utils';
49
+ import {
50
+ scrollTo, scrollToTop, scrollToBottom,
51
+ getScrollPosition, onScroll, isInViewport,
52
+ lockScroll, unlockScroll,
53
+ scrollSpy, onScrollEnd, scrollIntoViewIfNeeded,
54
+ easeScroll, Easings
55
+ } from 'scroll-snap-kit';
21
56
  ```
22
57
 
58
+ ---
59
+
23
60
  ### `scrollTo(target, options?)`
24
61
 
25
62
  Smoothly scroll to a DOM element or a Y pixel value.
26
63
 
27
64
  ```js
28
65
  scrollTo(document.querySelector('#section'));
29
- scrollTo(500); // scroll to y=500px
30
- scrollTo(document.querySelector('#hero'), { offset: -80 }); // with offset (e.g. sticky header)
66
+ scrollTo(500); // scroll to y=500px
67
+ scrollTo(document.querySelector('#hero'), { offset: -80 }); // offset for sticky headers
31
68
  ```
32
69
 
33
70
  | Option | Type | Default | Description |
34
71
  |--------|------|---------|-------------|
35
72
  | `behavior` | `'smooth' \| 'instant'` | `'smooth'` | Scroll behavior |
36
73
  | `block` | `ScrollLogicalPosition` | `'start'` | Vertical alignment |
37
- | `offset` | `number` | `0` | Pixel offset adjustment |
74
+ | `offset` | `number` | `0` | Pixel offset (e.g. `-80` for a sticky nav) |
38
75
 
39
76
  ---
40
77
 
@@ -49,45 +86,174 @@ scrollToBottom({ behavior: 'instant' });
49
86
 
50
87
  ### `getScrollPosition(container?)`
51
88
 
89
+ Returns the current scroll position and scroll percentage for the page or any scrollable container.
90
+
52
91
  ```js
53
92
  const { x, y, percentX, percentY } = getScrollPosition();
54
- // percentY = how far down the page (0–100)
93
+ // percentY how far down the page (0–100)
94
+
95
+ // Works on containers too
96
+ const pos = getScrollPosition(document.querySelector('.sidebar'));
55
97
  ```
56
98
 
57
99
  ---
58
100
 
59
101
  ### `onScroll(callback, options?)`
60
102
 
61
- Throttled scroll listener. Returns a cleanup function.
103
+ Throttled scroll listener. Returns a cleanup function to stop listening.
62
104
 
63
105
  ```js
64
- const stop = onScroll(({ y, percentY }) => {
106
+ const stop = onScroll(({ x, y, percentX, percentY }) => {
65
107
  console.log(`Scrolled ${percentY}% down`);
66
- }, { throttle: 150 });
108
+ }, { throttle: 100 });
67
109
 
68
- // Later:
69
110
  stop(); // removes the listener
70
111
  ```
71
112
 
113
+ | Option | Type | Default | Description |
114
+ |--------|------|---------|-------------|
115
+ | `throttle` | `number` | `100` | Minimum ms between callbacks |
116
+ | `container` | `Element` | `window` | Scrollable container to listen on |
117
+
72
118
  ---
73
119
 
74
120
  ### `isInViewport(element, options?)`
75
121
 
122
+ Check whether an element is currently visible in the viewport.
123
+
76
124
  ```js
77
125
  if (isInViewport(document.querySelector('.card'), { threshold: 0.5 })) {
78
126
  // At least 50% of the card is visible
79
127
  }
80
128
  ```
81
129
 
130
+ | Option | Type | Default | Description |
131
+ |--------|------|---------|-------------|
132
+ | `threshold` | `number` | `0` | 0–1 portion of element that must be visible |
133
+
82
134
  ---
83
135
 
84
136
  ### `lockScroll()` / `unlockScroll()`
85
137
 
86
- Lock page scroll (e.g. when a modal is open) and restore position on unlock.
138
+ Lock page scroll (e.g. when a modal is open) and restore the exact position on unlock — no layout jump.
139
+
140
+ ```js
141
+ lockScroll(); // body stops scrolling, position saved
142
+ unlockScroll(); // position restored precisely
143
+ ```
144
+
145
+ ---
146
+
147
+ ### `scrollSpy(sectionsSelector, linksSelector, options?)`
148
+
149
+ Watches scroll position and automatically adds an active class to the nav link matching the current section. Returns a cleanup function.
150
+
151
+ ```js
152
+ const stop = scrollSpy(
153
+ 'section[id]', // sections to spy on
154
+ 'nav a', // nav links to highlight
155
+ {
156
+ offset: 80, // px from top to trigger (default: 0)
157
+ activeClass: 'scroll-spy-active' // class to toggle (default: 'scroll-spy-active')
158
+ }
159
+ );
160
+
161
+ stop(); // remove the listener
162
+ ```
163
+
164
+ ```css
165
+ /* Style the active link however you like */
166
+ nav a.scroll-spy-active {
167
+ color: #00ffaa;
168
+ border-bottom: 1px solid currentColor;
169
+ }
170
+ ```
171
+
172
+ > Links are matched by comparing their `href` to `#sectionId`. Call `scrollSpy` multiple times to target different link groups simultaneously.
173
+
174
+ ---
175
+
176
+ ### `onScrollEnd(callback, options?)`
177
+
178
+ Fires a callback once the user has stopped scrolling for a configurable delay. Great for lazy-loading, analytics, or autosave.
179
+
180
+ ```js
181
+ const stop = onScrollEnd(() => {
182
+ console.log('User stopped scrolling!');
183
+ saveScrollPosition();
184
+ }, { delay: 200 });
185
+
186
+ stop(); // cleanup
187
+ ```
188
+
189
+ | Option | Type | Default | Description |
190
+ |--------|------|---------|-------------|
191
+ | `delay` | `number` | `150` | ms of idle scrolling before callback fires |
192
+ | `container` | `Element` | `window` | Scrollable container to watch |
193
+
194
+ ---
195
+
196
+ ### `scrollIntoViewIfNeeded(element, options?)`
197
+
198
+ Scrolls to an element only if it is partially or fully outside the visible viewport. If it's already visible enough, nothing happens — no unnecessary scroll.
199
+
200
+ ```js
201
+ // Only scrolls if the element is off-screen
202
+ scrollIntoViewIfNeeded(document.querySelector('.card'));
203
+
204
+ // threshold: how much must be visible before we skip scrolling
205
+ scrollIntoViewIfNeeded(element, { threshold: 0.5, offset: -80 });
206
+ ```
207
+
208
+ | Option | Type | Default | Description |
209
+ |--------|------|---------|-------------|
210
+ | `threshold` | `number` | `1` | 0–1 visibility ratio required to skip scrolling |
211
+ | `offset` | `number` | `0` | Pixel offset applied when scrolling |
212
+ | `behavior` | `'smooth' \| 'instant'` | `'smooth'` | Scroll behavior |
213
+
214
+ ---
215
+
216
+ ### `easeScroll(target, options?)` + `Easings`
217
+
218
+ Scroll to a position with a fully custom easing curve, bypassing the browser's native smooth scroll. Returns a `Promise` that resolves when the animation completes.
219
+
220
+ ```js
221
+ // Use a built-in easing
222
+ await easeScroll('#contact', {
223
+ duration: 800,
224
+ easing: Easings.easeOutElastic
225
+ });
226
+
227
+ // Chain animations
228
+ await easeScroll('#hero', { duration: 600, easing: Easings.easeInOutCubic });
229
+ await easeScroll('#features', { duration: 400, easing: Easings.easeOutQuart });
230
+
231
+ // BYO easing function — any (t: 0→1) => (0→1)
232
+ easeScroll(element, { easing: t => t * t * t });
233
+ ```
234
+
235
+ | Option | Type | Default | Description |
236
+ |--------|------|---------|-------------|
237
+ | `duration` | `number` | `600` | Animation duration in ms |
238
+ | `easing` | `(t: number) => number` | `Easings.easeInOutCubic` | Easing function |
239
+ | `offset` | `number` | `0` | Pixel offset applied to target position |
240
+
241
+ **Built-in easings:**
87
242
 
88
243
  ```js
89
- lockScroll(); // body stops scrolling
90
- unlockScroll(); // restored to previous position
244
+ import { Easings } from 'scroll-snap-kit';
245
+
246
+ Easings.linear
247
+ Easings.easeInQuad
248
+ Easings.easeOutQuad
249
+ Easings.easeInOutQuad
250
+ Easings.easeInCubic
251
+ Easings.easeOutCubic
252
+ Easings.easeInOutCubic // ← default
253
+ Easings.easeInQuart
254
+ Easings.easeOutQuart
255
+ Easings.easeOutElastic // springy overshoot
256
+ Easings.easeOutBounce // bouncy landing
91
257
  ```
92
258
 
93
259
  ---
@@ -95,28 +261,55 @@ unlockScroll(); // restored to previous position
95
261
  ## React Hooks
96
262
 
97
263
  ```js
98
- import { useScrollPosition, useInViewport, useScrollTo, useScrolledPast, useScrollDirection } from 'scroll-snap-kit/hooks';
264
+ import {
265
+ useScrollPosition,
266
+ useInViewport,
267
+ useScrollTo,
268
+ useScrolledPast,
269
+ useScrollDirection
270
+ } from 'scroll-snap-kit/hooks';
99
271
  ```
100
272
 
273
+ > Requires React 16.8+. React is a peer dependency — install it separately.
274
+
275
+ ---
276
+
101
277
  ### `useScrollPosition(options?)`
102
278
 
279
+ Returns the current scroll position, updated live on scroll.
280
+
103
281
  ```jsx
104
282
  function ProgressBar() {
105
- const { percentY } = useScrollPosition({ throttle: 50 });
106
- return <div style={{ width: `${percentY}%` }} className="progress" />;
283
+ const { x, y, percentX, percentY } = useScrollPosition({ throttle: 50 });
284
+ return <div style={{ width: `${percentY}%` }} className="progress-bar" />;
107
285
  }
108
286
  ```
109
287
 
288
+ | Option | Type | Default | Description |
289
+ |--------|------|---------|-------------|
290
+ | `throttle` | `number` | `100` | ms between updates |
291
+ | `container` | `Element` | `window` | Scrollable container |
292
+
110
293
  ---
111
294
 
112
295
  ### `useInViewport(options?)`
113
296
 
297
+ Returns a `[ref, inView]` tuple. Attach `ref` to any element to track its viewport visibility using `IntersectionObserver`.
298
+
114
299
  ```jsx
115
- function FadeIn() {
300
+ function FadeInCard() {
116
301
  const [ref, inView] = useInViewport({ threshold: 0.2, once: true });
302
+
117
303
  return (
118
- <div ref={ref} style={{ opacity: inView ? 1 : 0, transition: 'opacity 0.5s' }}>
119
- I fade in when visible!
304
+ <div
305
+ ref={ref}
306
+ style={{
307
+ opacity: inView ? 1 : 0,
308
+ transform: inView ? 'translateY(0)' : 'translateY(20px)',
309
+ transition: 'all 0.5s ease'
310
+ }}
311
+ >
312
+ I animate in when visible!
120
313
  </div>
121
314
  );
122
315
  }
@@ -125,20 +318,24 @@ function FadeIn() {
125
318
  | Option | Type | Default | Description |
126
319
  |--------|------|---------|-------------|
127
320
  | `threshold` | `number` | `0` | 0–1 portion of element visible to trigger |
128
- | `once` | `boolean` | `false` | Only trigger the first time |
321
+ | `once` | `boolean` | `false` | Only trigger on first entry, then stop observing |
129
322
 
130
323
  ---
131
324
 
132
325
  ### `useScrollTo()`
133
326
 
327
+ Returns a `[containerRef, scrollToTarget]` tuple. Scope smooth scrolling to a specific scrollable container, or fall back to window scroll.
328
+
134
329
  ```jsx
135
- function Page() {
330
+ function Sidebar() {
136
331
  const [containerRef, scrollToTarget] = useScrollTo();
137
332
  const sectionRef = useRef(null);
138
333
 
139
334
  return (
140
335
  <div ref={containerRef} style={{ overflowY: 'scroll', height: '400px' }}>
141
- <button onClick={() => scrollToTarget(sectionRef.current)}>Jump to section</button>
336
+ <button onClick={() => scrollToTarget(sectionRef.current, { offset: -16 })}>
337
+ Jump to section
338
+ </button>
142
339
  <div style={{ height: 300 }} />
143
340
  <div ref={sectionRef}>Target section</div>
144
341
  </div>
@@ -150,32 +347,83 @@ function Page() {
150
347
 
151
348
  ### `useScrolledPast(threshold?, options?)`
152
349
 
350
+ Returns `true` once the user has scrolled past a given pixel value. Useful for showing back-to-top buttons, sticky CTAs, and similar patterns.
351
+
153
352
  ```jsx
154
353
  function BackToTopButton() {
155
354
  const scrolledPast = useScrolledPast(300);
156
- return scrolledPast ? <button onClick={scrollToTop}>↑ Top</button> : null;
355
+ return scrolledPast ? (
356
+ <button onClick={() => scrollToTop()}>↑ Back to top</button>
357
+ ) : null;
157
358
  }
158
359
  ```
159
360
 
361
+ | Param | Type | Default | Description |
362
+ |-------|------|---------|-------------|
363
+ | `threshold` | `number` | `100` | Y pixel value to check against |
364
+ | `options.container` | `Element` | `window` | Scrollable container |
365
+
160
366
  ---
161
367
 
162
368
  ### `useScrollDirection()`
163
369
 
370
+ Returns the current scroll direction: `'up'`, `'down'`, or `null` on initial render.
371
+
164
372
  ```jsx
165
- function Navbar() {
373
+ function HideOnScrollNav() {
166
374
  const direction = useScrollDirection();
375
+
167
376
  return (
168
- <nav style={{ transform: direction === 'down' ? 'translateY(-100%)' : 'translateY(0)' }}>
377
+ <nav style={{
378
+ transform: direction === 'down' ? 'translateY(-100%)' : 'translateY(0)',
379
+ transition: 'transform 0.3s ease'
380
+ }}>
169
381
  My Navbar
170
382
  </nav>
171
383
  );
172
384
  }
173
385
  ```
174
386
 
175
- Returns `'up'`, `'down'`, or `null` (on initial render).
387
+ | Option | Type | Default | Description |
388
+ |--------|------|---------|-------------|
389
+ | `throttle` | `number` | `100` | ms between direction checks |
390
+
391
+ ---
392
+
393
+ ## Tree-shaking
394
+
395
+ All exports are named and side-effect free — you only ship what you import:
396
+
397
+ ```js
398
+ // Only pulls in ~400 bytes
399
+ import { scrollToTop } from 'scroll-snap-kit';
400
+
401
+ // Import utils and hooks separately for maximum tree-shaking
402
+ import { onScroll, scrollSpy } from 'scroll-snap-kit/utils';
403
+ import { useScrollPosition } from 'scroll-snap-kit/hooks';
404
+ ```
405
+
406
+ ---
407
+
408
+ ## Browser support
409
+
410
+ All modern browsers (Chrome, Firefox, Safari, Edge). `easeScroll` uses `requestAnimationFrame`. `useInViewport` and `isInViewport` use `IntersectionObserver` — supported everywhere modern; polyfill if you need IE11.
411
+
412
+ ---
413
+
414
+ ## Changelog
415
+
416
+ ### v1.1.0
417
+ - ✨ `scrollSpy()` — highlight nav links by active section
418
+ - ✨ `onScrollEnd()` — callback when scrolling stops
419
+ - ✨ `scrollIntoViewIfNeeded()` — scroll only when off-screen
420
+ - ✨ `easeScroll()` + `Easings` — custom easing engine with 11 built-in curves
421
+
422
+ ### v1.0.0
423
+ - 🎉 Initial release — 8 core utilities and 5 React hooks
176
424
 
177
425
  ---
178
426
 
179
427
  ## License
180
428
 
181
- MIT
429
+ MIT © Fabian Faraz Farid
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scroll-snap-kit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Smooth scroll utilities and React hooks for modern web apps",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -44,6 +44,10 @@
44
44
  },
45
45
  "repository": {
46
46
  "type": "git",
47
- "url": "https://github.com/farazfarid/scroll-snap-kit"
48
- }
49
- }
47
+ "url": "git+https://github.com/farazfarid/scroll-snap-kit.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://github.com/farazfarid/scroll-snap-kit/issues"
51
+ },
52
+ "homepage": "https://farazfarid.github.io/scroll-snap-kit/"
53
+ }
package/src/index.js CHANGED
@@ -11,6 +11,12 @@ export {
11
11
  isInViewport,
12
12
  lockScroll,
13
13
  unlockScroll,
14
+ // New in v1.1
15
+ scrollSpy,
16
+ onScrollEnd,
17
+ scrollIntoViewIfNeeded,
18
+ easeScroll,
19
+ Easings,
14
20
  } from './utils.js';
15
21
 
16
22
  export {
package/src/utils.js CHANGED
@@ -151,4 +151,187 @@ export function unlockScroll() {
151
151
  document.body.style.top = '';
152
152
  document.body.style.width = '';
153
153
  window.scrollTo(0, parseInt(scrollY || '0') * -1);
154
+ }
155
+
156
+ // ─────────────────────────────────────────────
157
+ // NEW FEATURES
158
+ // ─────────────────────────────────────────────
159
+
160
+ /**
161
+ * scrollSpy — watches scroll position and highlights nav links
162
+ * matching the currently active section.
163
+ *
164
+ * @param {string} sectionsSelector CSS selector for the sections to spy on
165
+ * @param {string} linksSelector CSS selector for the nav links
166
+ * @param {{ offset?: number, activeClass?: string }} options
167
+ * @returns {() => void} cleanup / stop function
168
+ *
169
+ * @example
170
+ * const stop = scrollSpy('section[id]', 'nav a', { offset: 80, activeClass: 'active' })
171
+ */
172
+ export function scrollSpy(sectionsSelector, linksSelector, options = {}) {
173
+ const { offset = 0, activeClass = 'scroll-spy-active' } = options;
174
+
175
+ const sections = Array.from(document.querySelectorAll(sectionsSelector));
176
+ const links = Array.from(document.querySelectorAll(linksSelector));
177
+
178
+ if (!sections.length || !links.length) {
179
+ console.warn('[scroll-snap-kit] scrollSpy: no sections or links found');
180
+ return () => { };
181
+ }
182
+
183
+ function update() {
184
+ const scrollY = window.scrollY + offset;
185
+ let current = sections[0];
186
+
187
+ for (const section of sections) {
188
+ if (section.offsetTop <= scrollY) current = section;
189
+ }
190
+
191
+ links.forEach(link => {
192
+ link.classList.remove(activeClass);
193
+ const href = link.getAttribute('href');
194
+ if (href && current && href === `#${current.id}`) {
195
+ link.classList.add(activeClass);
196
+ }
197
+ });
198
+ }
199
+
200
+ update();
201
+ window.addEventListener('scroll', update, { passive: true });
202
+ return () => window.removeEventListener('scroll', update);
203
+ }
204
+
205
+ /**
206
+ * onScrollEnd — fires a callback once the user stops scrolling.
207
+ *
208
+ * @param {() => void} callback
209
+ * @param {{ delay?: number, container?: Element }} options
210
+ * @returns {() => void} cleanup function
211
+ *
212
+ * @example
213
+ * const stop = onScrollEnd(() => console.log('Scrolling stopped!'), { delay: 150 })
214
+ */
215
+ export function onScrollEnd(callback, options = {}) {
216
+ const { delay = 150, container } = options;
217
+ const target = container || window;
218
+ let timer = null;
219
+
220
+ const handler = () => {
221
+ clearTimeout(timer);
222
+ timer = setTimeout(() => {
223
+ callback(getScrollPosition(container));
224
+ }, delay);
225
+ };
226
+
227
+ target.addEventListener('scroll', handler, { passive: true });
228
+ return () => {
229
+ clearTimeout(timer);
230
+ target.removeEventListener('scroll', handler);
231
+ };
232
+ }
233
+
234
+ /**
235
+ * scrollIntoViewIfNeeded — scrolls to an element only if it is
236
+ * partially or fully outside the visible viewport.
237
+ *
238
+ * @param {Element} element
239
+ * @param {{ behavior?: ScrollBehavior, offset?: number, threshold?: number }} options
240
+ * threshold: 0–1, how much of the element must be visible before we skip scrolling (default 1 = fully visible)
241
+ *
242
+ * @example
243
+ * scrollIntoViewIfNeeded(document.querySelector('.card'))
244
+ */
245
+ export function scrollIntoViewIfNeeded(element, options = {}) {
246
+ if (!(element instanceof Element)) {
247
+ console.warn('[scroll-snap-kit] scrollIntoViewIfNeeded: argument must be an Element');
248
+ return;
249
+ }
250
+
251
+ const { behavior = 'smooth', offset = 0, threshold = 1 } = options;
252
+ const rect = element.getBoundingClientRect();
253
+ const wh = window.innerHeight || document.documentElement.clientHeight;
254
+ const ww = window.innerWidth || document.documentElement.clientWidth;
255
+
256
+ const visibleH = Math.min(rect.bottom, wh) - Math.max(rect.top, 0);
257
+ const visibleW = Math.min(rect.right, ww) - Math.max(rect.left, 0);
258
+ const visibleRatio =
259
+ (Math.max(0, visibleH) * Math.max(0, visibleW)) / (rect.height * rect.width);
260
+
261
+ if (visibleRatio >= threshold) return; // already sufficiently visible — skip
262
+
263
+ const y = rect.top + window.scrollY - offset;
264
+ window.scrollTo({ top: y, behavior });
265
+ }
266
+
267
+ /**
268
+ * Built-in easing functions for use with easeScroll().
269
+ */
270
+ export const Easings = {
271
+ linear: (t) => t,
272
+ easeInQuad: (t) => t * t,
273
+ easeOutQuad: (t) => t * (2 - t),
274
+ easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t,
275
+ easeInCubic: (t) => t * t * t,
276
+ easeOutCubic: (t) => (--t) * t * t + 1,
277
+ easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1,
278
+ easeInQuart: (t) => t * t * t * t,
279
+ easeOutQuart: (t) => 1 - (--t) * t * t * t,
280
+ easeOutElastic: (t) => {
281
+ const c4 = (2 * Math.PI) / 3;
282
+ return t === 0 ? 0 : t === 1 ? 1
283
+ : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1;
284
+ },
285
+ easeOutBounce: (t) => {
286
+ const n1 = 7.5625, d1 = 2.75;
287
+ if (t < 1 / d1) return n1 * t * t;
288
+ if (t < 2 / d1) return n1 * (t -= 1.5 / d1) * t + 0.75;
289
+ if (t < 2.5 / d1) return n1 * (t -= 2.25 / d1) * t + 0.9375;
290
+ return n1 * (t -= 2.625 / d1) * t + 0.984375;
291
+ },
292
+ };
293
+
294
+ /**
295
+ * easeScroll — scroll to a position with a custom easing curve,
296
+ * bypassing the browser's native smooth scroll.
297
+ *
298
+ * @param {Element|number} target DOM element or pixel Y value
299
+ * @param {{ duration?: number, easing?: (t: number) => number, offset?: number }} options
300
+ * @returns {Promise<void>} resolves when animation completes
301
+ *
302
+ * @example
303
+ * await easeScroll('#contact', { duration: 800, easing: Easings.easeOutElastic })
304
+ */
305
+ export function easeScroll(target, options = {}) {
306
+ const { duration = 600, easing = Easings.easeInOutCubic, offset = 0 } = options;
307
+
308
+ let targetY;
309
+ if (typeof target === 'number') {
310
+ targetY = target + offset;
311
+ } else {
312
+ const el = typeof target === 'string' ? document.querySelector(target) : target;
313
+ if (!el) { console.warn('[scroll-snap-kit] easeScroll: target not found'); return Promise.resolve(); }
314
+ targetY = el.getBoundingClientRect().top + window.scrollY + offset;
315
+ }
316
+
317
+ const startY = window.scrollY;
318
+ const distance = targetY - startY;
319
+ const startTime = performance.now();
320
+
321
+ return new Promise((resolve) => {
322
+ function step(now) {
323
+ const elapsed = now - startTime;
324
+ const progress = Math.min(elapsed / duration, 1);
325
+ const easedProgress = easing(progress);
326
+
327
+ window.scrollTo(0, startY + distance * easedProgress);
328
+
329
+ if (progress < 1) {
330
+ requestAnimationFrame(step);
331
+ } else {
332
+ resolve();
333
+ }
334
+ }
335
+ requestAnimationFrame(step);
336
+ });
154
337
  }