@usefy/use-intersection-observer 0.0.1

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 ADDED
@@ -0,0 +1,514 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/mirunamu00/usefy/master/assets/logo.png" alt="usefy logo" width="120" />
3
+ </p>
4
+
5
+ <h1 align="center">@usefy/use-intersection-observer</h1>
6
+
7
+ <p align="center">
8
+ <strong>A powerful React hook for observing element visibility using the Intersection Observer API</strong>
9
+ </p>
10
+
11
+ <p align="center">
12
+ <a href="https://www.npmjs.com/package/@usefy/use-intersection-observer">
13
+ <img src="https://img.shields.io/npm/v/@usefy/use-intersection-observer.svg?style=flat-square&color=007acc" alt="npm version" />
14
+ </a>
15
+ <a href="https://www.npmjs.com/package/@usefy/use-intersection-observer">
16
+ <img src="https://img.shields.io/npm/dm/@usefy/use-intersection-observer.svg?style=flat-square&color=007acc" alt="npm downloads" />
17
+ </a>
18
+ <a href="https://bundlephobia.com/package/@usefy/use-intersection-observer">
19
+ <img src="https://img.shields.io/bundlephobia/minzip/@usefy/use-intersection-observer?style=flat-square&color=007acc" alt="bundle size" />
20
+ </a>
21
+ <a href="https://github.com/mirunamu00/usefy/blob/master/LICENSE">
22
+ <img src="https://img.shields.io/npm/l/@usefy/use-intersection-observer.svg?style=flat-square&color=007acc" alt="license" />
23
+ </a>
24
+ </p>
25
+
26
+ <p align="center">
27
+ <a href="#installation">Installation</a> •
28
+ <a href="#quick-start">Quick Start</a> •
29
+ <a href="#api-reference">API Reference</a> •
30
+ <a href="#examples">Examples</a> •
31
+ <a href="#license">License</a>
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://mirunamu00.github.io/usefy/?path=/docs/hooks-useintersectionobserver--docs" target="_blank" rel="noopener noreferrer">
36
+ <strong>📚 View Storybook Demo</strong>
37
+ </a>
38
+ </p>
39
+
40
+ ---
41
+
42
+ ## Overview
43
+
44
+ `@usefy/use-intersection-observer` is a feature-rich React hook for efficiently detecting element visibility in the viewport using the Intersection Observer API. It provides a simple API for lazy loading, infinite scroll, scroll animations, and more.
45
+
46
+ **Part of the [@usefy](https://www.npmjs.com/org/usefy) ecosystem** — a collection of production-ready React hooks designed for modern applications.
47
+
48
+ ### Why use-intersection-observer?
49
+
50
+ - **Zero Dependencies** — Pure React implementation with no external dependencies
51
+ - **TypeScript First** — Full type safety with comprehensive type definitions
52
+ - **Efficient Detection** — Leverages native Intersection Observer API for optimal performance
53
+ - **Threshold-based Callbacks** — Fine-grained visibility ratio tracking with multiple thresholds
54
+ - **TriggerOnce Support** — Perfect for lazy loading patterns
55
+ - **Dynamic Enable/Disable** — Conditional observation support
56
+ - **Custom Root Containers** — Observe elements within custom scroll containers
57
+ - **Root Margin Support** — Expand or shrink detection boundaries
58
+ - **SSR Compatible** — Works seamlessly with Next.js, Remix, and other SSR frameworks
59
+ - **Optimized Re-renders** — Only updates when meaningful values change
60
+ - **Well Tested** — Comprehensive test coverage with Vitest
61
+
62
+ ---
63
+
64
+ ## Installation
65
+
66
+ ```bash
67
+ # npm
68
+ npm install @usefy/use-intersection-observer
69
+
70
+ # yarn
71
+ yarn add @usefy/use-intersection-observer
72
+
73
+ # pnpm
74
+ pnpm add @usefy/use-intersection-observer
75
+ ```
76
+
77
+ ### Peer Dependencies
78
+
79
+ This package requires React 18 or 19:
80
+
81
+ ```json
82
+ {
83
+ "peerDependencies": {
84
+ "react": "^18.0.0 || ^19.0.0"
85
+ }
86
+ }
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Quick Start
92
+
93
+ ```tsx
94
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
95
+
96
+ function MyComponent() {
97
+ const { ref, inView, entry } = useIntersectionObserver();
98
+
99
+ return <div ref={ref}>{inView ? "👁️ Visible!" : "👻 Not visible"}</div>;
100
+ }
101
+ ```
102
+
103
+ ---
104
+
105
+ ## API Reference
106
+
107
+ ### `useIntersectionObserver(options?)`
108
+
109
+ A hook that observes element visibility using the Intersection Observer API.
110
+
111
+ #### Parameters
112
+
113
+ | Parameter | Type | Description |
114
+ | --------- | -------------------------------- | ----------------------------- |
115
+ | `options` | `UseIntersectionObserverOptions` | Optional configuration object |
116
+
117
+ #### Options
118
+
119
+ | Option | Type | Default | Description |
120
+ | ----------------------- | ----------------------------------------------------- | ------- | -------------------------------------------------------- |
121
+ | `threshold` | `number \| number[]` | `0` | Threshold(s) at which callback is triggered (0.0 to 1.0) |
122
+ | `root` | `Element \| Document \| null` | `null` | Root element for intersection (null = viewport) |
123
+ | `rootMargin` | `string` | `"0px"` | Margin around root (CSS margin syntax) |
124
+ | `triggerOnce` | `boolean` | `false` | Stop observing after first intersection |
125
+ | `enabled` | `boolean` | `true` | Enable/disable the observer dynamically |
126
+ | `initialIsIntersecting` | `boolean` | `false` | Initial intersection state (useful for SSR/SSG) |
127
+ | `onChange` | `(entry: IntersectionEntry, inView: boolean) => void` | — | Callback when intersection state changes |
128
+ | `delay` | `number` | `0` | Delay in milliseconds before creating observer |
129
+
130
+ #### Returns `UseIntersectionObserverReturn`
131
+
132
+ | Property | Type | Description |
133
+ | -------- | --------------------------------- | -------------------------------------------------- |
134
+ | `entry` | `IntersectionEntry \| null` | Intersection entry data (null if not yet observed) |
135
+ | `inView` | `boolean` | Whether the element is currently in view |
136
+ | `ref` | `(node: Element \| null) => void` | Ref callback to attach to target element |
137
+
138
+ #### `IntersectionEntry`
139
+
140
+ Extended intersection entry with convenience properties:
141
+
142
+ | Property | Type | Description |
143
+ | -------------------- | --------------------------- | ---------------------------------------- |
144
+ | `entry` | `IntersectionObserverEntry` | Original native entry |
145
+ | `isIntersecting` | `boolean` | Whether target is intersecting with root |
146
+ | `intersectionRatio` | `number` | Ratio of target visible (0.0 to 1.0) |
147
+ | `target` | `Element` | The observed element |
148
+ | `boundingClientRect` | `DOMRectReadOnly` | Bounding rectangle of target element |
149
+ | `intersectionRect` | `DOMRectReadOnly` | Visible portion of target element |
150
+ | `rootBounds` | `DOMRectReadOnly \| null` | Bounding rectangle of root element |
151
+ | `time` | `number` | Timestamp when intersection was recorded |
152
+
153
+ ---
154
+
155
+ ## Examples
156
+
157
+ ### Basic Usage
158
+
159
+ ```tsx
160
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
161
+
162
+ function VisibilityChecker() {
163
+ const { ref, inView } = useIntersectionObserver();
164
+
165
+ return <div ref={ref}>{inView ? "👁️ Visible!" : "👻 Not visible"}</div>;
166
+ }
167
+ ```
168
+
169
+ ### Lazy Loading Images
170
+
171
+ ```tsx
172
+ import { useState } from "react";
173
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
174
+
175
+ function LazyImage({ src, alt }: { src: string; alt: string }) {
176
+ const [loaded, setLoaded] = useState(false);
177
+
178
+ const { ref, inView } = useIntersectionObserver({
179
+ triggerOnce: true,
180
+ threshold: 0.1,
181
+ rootMargin: "50px", // Preload 50px ahead
182
+ });
183
+
184
+ return (
185
+ <div ref={ref}>
186
+ {inView ? (
187
+ <img
188
+ src={src}
189
+ alt={alt}
190
+ onLoad={() => setLoaded(true)}
191
+ style={{ opacity: loaded ? 1 : 0 }}
192
+ />
193
+ ) : (
194
+ <div className="placeholder">Loading...</div>
195
+ )}
196
+ </div>
197
+ );
198
+ }
199
+ ```
200
+
201
+ ### Infinite Scroll
202
+
203
+ ```tsx
204
+ import { useState, useEffect } from "react";
205
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
206
+
207
+ function InfiniteList() {
208
+ const [items, setItems] = useState([...initialItems]);
209
+ const [loading, setLoading] = useState(false);
210
+
211
+ const { ref, inView } = useIntersectionObserver({
212
+ threshold: 1.0,
213
+ rootMargin: "100px", // Preload 100px ahead
214
+ });
215
+
216
+ useEffect(() => {
217
+ if (inView && !loading) {
218
+ setLoading(true);
219
+ fetchMoreItems().then((newItems) => {
220
+ setItems((prev) => [...prev, ...newItems]);
221
+ setLoading(false);
222
+ });
223
+ }
224
+ }, [inView, loading]);
225
+
226
+ return (
227
+ <div>
228
+ {items.map((item) => (
229
+ <Item key={item.id} {...item} />
230
+ ))}
231
+ {/* Sentinel Element */}
232
+ <div ref={ref}>{loading && <Spinner />}</div>
233
+ </div>
234
+ );
235
+ }
236
+ ```
237
+
238
+ ### Scroll Animations
239
+
240
+ ```tsx
241
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
242
+
243
+ function AnimatedCard({ children }: { children: React.ReactNode }) {
244
+ const { ref, inView } = useIntersectionObserver({
245
+ triggerOnce: true,
246
+ threshold: 0.3,
247
+ });
248
+
249
+ return (
250
+ <div
251
+ ref={ref}
252
+ style={{
253
+ opacity: inView ? 1 : 0,
254
+ transform: inView ? "translateY(0)" : "translateY(30px)",
255
+ transition: "all 0.6s ease",
256
+ }}
257
+ >
258
+ {children}
259
+ </div>
260
+ );
261
+ }
262
+ ```
263
+
264
+ ### Reading Progress Tracker
265
+
266
+ ```tsx
267
+ import { useState } from "react";
268
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
269
+
270
+ function ProgressTracker() {
271
+ const [progress, setProgress] = useState(0);
272
+
273
+ // 101 thresholds (0%, 1%, 2%, ... 100%)
274
+ const thresholds = Array.from({ length: 101 }, (_, i) => i / 100);
275
+
276
+ const { ref } = useIntersectionObserver({
277
+ threshold: thresholds,
278
+ onChange: (entry) => {
279
+ setProgress(Math.round(entry.intersectionRatio * 100));
280
+ },
281
+ });
282
+
283
+ return (
284
+ <>
285
+ <div className="progress-bar" style={{ width: `${progress}%` }} />
286
+ <article ref={ref}>{/* Long content */}</article>
287
+ </>
288
+ );
289
+ }
290
+ ```
291
+
292
+ ### Custom Scroll Container
293
+
294
+ ```tsx
295
+ import { useRef } from "react";
296
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
297
+
298
+ function ScrollContainer() {
299
+ const containerRef = useRef<HTMLDivElement>(null);
300
+
301
+ const { ref, inView } = useIntersectionObserver({
302
+ root: containerRef.current,
303
+ rootMargin: "0px",
304
+ });
305
+
306
+ return (
307
+ <div ref={containerRef} style={{ overflow: "auto", height: 400 }}>
308
+ <div style={{ height: 1000 }}>
309
+ <div ref={ref}>{inView ? "Visible in container" : "Not visible"}</div>
310
+ </div>
311
+ </div>
312
+ );
313
+ }
314
+ ```
315
+
316
+ ### Section Navigation Highlighting
317
+
318
+ ```tsx
319
+ import { useState } from "react";
320
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
321
+
322
+ function SectionNavigation() {
323
+ const [activeSection, setActiveSection] = useState<string | null>(null);
324
+
325
+ return (
326
+ <>
327
+ <nav>
328
+ {sections.map((section) => (
329
+ <button
330
+ key={section.id}
331
+ className={activeSection === section.id ? "active" : ""}
332
+ >
333
+ {section.name}
334
+ </button>
335
+ ))}
336
+ </nav>
337
+
338
+ {sections.map((section) => (
339
+ <Section
340
+ key={section.id}
341
+ id={section.id}
342
+ onVisible={() => setActiveSection(section.id)}
343
+ />
344
+ ))}
345
+ </>
346
+ );
347
+ }
348
+
349
+ function Section({ id, onVisible }: { id: string; onVisible: () => void }) {
350
+ const { ref } = useIntersectionObserver({
351
+ threshold: 0.6, // Activate when 60% visible
352
+ onChange: (_, inView) => {
353
+ if (inView) onVisible();
354
+ },
355
+ });
356
+
357
+ return (
358
+ <section ref={ref} id={id}>
359
+ ...
360
+ </section>
361
+ );
362
+ }
363
+ ```
364
+
365
+ ### Dynamic Enable/Disable
366
+
367
+ ```tsx
368
+ import { useState } from "react";
369
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
370
+
371
+ function ConditionalObserver() {
372
+ const [isLoading, setIsLoading] = useState(true);
373
+
374
+ const { ref, inView } = useIntersectionObserver({
375
+ enabled: !isLoading, // Disable while loading
376
+ });
377
+
378
+ return <div ref={ref}>{inView ? "Observing" : "Not observing"}</div>;
379
+ }
380
+ ```
381
+
382
+ ### SSR/SSG Support
383
+
384
+ ```tsx
385
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
386
+
387
+ function SSRComponent() {
388
+ // Assume above-the-fold content is initially visible
389
+ const { ref, inView } = useIntersectionObserver({
390
+ initialIsIntersecting: true,
391
+ });
392
+
393
+ // On SSR, inView will be true on first render
394
+ return <div ref={ref}>{inView ? "Initially visible" : "Not visible"}</div>;
395
+ }
396
+ ```
397
+
398
+ ### Delay Observer Creation
399
+
400
+ ```tsx
401
+ import { useIntersectionObserver } from "@usefy/use-intersection-observer";
402
+
403
+ function DelayedObserver() {
404
+ // Delay observer creation by 500ms
405
+ // Useful for preventing premature observations during fast scrolling
406
+ const { ref, inView } = useIntersectionObserver({
407
+ delay: 500,
408
+ });
409
+
410
+ return <div ref={ref}>{inView ? "Observing" : "Not observing"}</div>;
411
+ }
412
+ ```
413
+
414
+ ---
415
+
416
+ ## Performance Optimization
417
+
418
+ The hook is optimized to **only trigger re-renders when meaningful visibility values change**, not on every intersection callback. This means:
419
+
420
+ - ✅ Re-renders when `isIntersecting` changes (element enters/exits view)
421
+ - ✅ Re-renders when `intersectionRatio` changes (visibility percentage changes)
422
+ - ❌ Does NOT re-render when only `time` changes (time updates on every intersection callback, but doesn't trigger re-renders alone)
423
+
424
+ When an intersection occurs, the `time` property is updated with a new timestamp, but the hook compares `isIntersecting` and `intersectionRatio` to determine if a re-render is needed. This prevents unnecessary re-renders during scrolling while maintaining accurate visibility detection.
425
+
426
+ ---
427
+
428
+ ## TypeScript
429
+
430
+ This hook is written in TypeScript and exports comprehensive type definitions.
431
+
432
+ ```tsx
433
+ import {
434
+ useIntersectionObserver,
435
+ type UseIntersectionObserverOptions,
436
+ type UseIntersectionObserverReturn,
437
+ type IntersectionEntry,
438
+ type OnChangeCallback,
439
+ } from "@usefy/use-intersection-observer";
440
+
441
+ // Full type inference
442
+ const { ref, inView, entry }: UseIntersectionObserverReturn =
443
+ useIntersectionObserver({
444
+ threshold: 0.5,
445
+ onChange: (entry, inView) => {
446
+ console.log("Visibility changed:", inView);
447
+ },
448
+ });
449
+ ```
450
+
451
+ ---
452
+
453
+ ## Performance
454
+
455
+ - **Stable Function References** — The `ref` callback is memoized with `useCallback`
456
+ - **Smart Re-renders** — Only re-renders when `isIntersecting` or `intersectionRatio` changes
457
+ - **Native API** — Leverages browser's Intersection Observer API for optimal performance
458
+ - **SSR Compatible** — Gracefully degrades in server environments
459
+
460
+ ```tsx
461
+ const { ref } = useIntersectionObserver({
462
+ threshold: [0, 0.5, 1.0],
463
+ });
464
+
465
+ // ref reference remains stable across renders
466
+ useEffect(() => {
467
+ // Safe to use as dependency
468
+ }, [ref]);
469
+ ```
470
+
471
+ ---
472
+
473
+ ## Browser Support
474
+
475
+ This hook uses the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API), which is supported in:
476
+
477
+ - Chrome 51+
478
+ - Firefox 55+
479
+ - Safari 12.1+
480
+ - Edge 15+
481
+ - Opera 38+
482
+
483
+ For unsupported browsers, the hook gracefully degrades and returns the initial state.
484
+
485
+ ---
486
+
487
+ ## Testing
488
+
489
+ This package maintains comprehensive test coverage to ensure reliability and stability.
490
+
491
+ ### Test Coverage
492
+
493
+ 📊 <a href="https://mirunamu00.github.io/usefy/coverage/use-intersection-observer/src/index.html" target="_blank" rel="noopener noreferrer"><strong>View Detailed Coverage Report</strong></a> (GitHub Pages)
494
+
495
+ ### Test Files
496
+
497
+ - `useIntersectionObserver.test.ts` — 87 tests for hook behavior
498
+ - `utils.test.ts` — 63 tests for utility functions
499
+
500
+ **Total: 150 tests**
501
+
502
+ ---
503
+
504
+ ## License
505
+
506
+ MIT © [mirunamu](https://github.com/mirunamu00)
507
+
508
+ This package is part of the [usefy](https://github.com/mirunamu00/usefy) monorepo.
509
+
510
+ ---
511
+
512
+ <p align="center">
513
+ <sub>Built with care by the usefy team</sub>
514
+ </p>