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 +274 -26
- package/package.json +8 -4
- package/src/index.js +6 -0
- package/src/utils.js +183 -0
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
|
|
5
5
|
Zero dependencies. Tree-shakeable. Works with or without React.
|
|
6
6
|
|
|
7
|
+
[](https://www.npmjs.com/package/scroll-snap-kit)
|
|
8
|
+
[](./LICENSE)
|
|
9
|
+
[](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 {
|
|
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);
|
|
30
|
-
scrollTo(document.querySelector('#hero'), { offset: -80 }); //
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
90
|
-
|
|
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 {
|
|
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
|
|
300
|
+
function FadeInCard() {
|
|
116
301
|
const [ref, inView] = useInViewport({ threshold: 0.2, once: true });
|
|
302
|
+
|
|
117
303
|
return (
|
|
118
|
-
<div
|
|
119
|
-
|
|
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
|
|
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
|
|
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)}>
|
|
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 ?
|
|
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
|
|
373
|
+
function HideOnScrollNav() {
|
|
166
374
|
const direction = useScrollDirection();
|
|
375
|
+
|
|
167
376
|
return (
|
|
168
|
-
<nav style={{
|
|
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
|
-
|
|
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.
|
|
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
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
|
}
|