lazyvid 0.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/LICENSE +21 -0
- package/README.md +147 -0
- package/dist/index.d.mts +17 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +111 -0
- package/dist/index.mjs +84 -0
- package/package.json +48 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Artem Tyshchuk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# lazyvid
|
|
2
|
+
|
|
3
|
+
Lazy-load `<video>` sources with predictable behavior.
|
|
4
|
+
|
|
5
|
+
`lazyvid` renders an empty `<video>` element and injects `<source>` tags only when the element enters the viewport.
|
|
6
|
+
Until then — nothing downloads.
|
|
7
|
+
|
|
8
|
+
It relies on IntersectionObserver for visibility tracking and lets the browser handle native format selection (`webm`, `mp4`, etc).
|
|
9
|
+
|
|
10
|
+
~2 KB. Zero dependencies. Full TypeScript support.
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
<LazyVideo
|
|
14
|
+
sources={[
|
|
15
|
+
{ src: "/hero.webm", type: "video/webm" },
|
|
16
|
+
{ src: "/hero.mp4", type: "video/mp4" },
|
|
17
|
+
]}
|
|
18
|
+
muted
|
|
19
|
+
autoPlay
|
|
20
|
+
loop
|
|
21
|
+
/>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Single component. No additional setup.
|
|
25
|
+
|
|
26
|
+
## Why?
|
|
27
|
+
|
|
28
|
+
Browsers start fetching `<video>` sources as soon as `<source>` elements are present in the DOM.
|
|
29
|
+
On media-heavy pages, this means unnecessary bandwidth usage and background CPU activity — even if the user never scrolls to the video.
|
|
30
|
+
|
|
31
|
+
`lazyvid` avoids that by delaying source injection until the element becomes visible (with configurable preload offset).
|
|
32
|
+
|
|
33
|
+
- No user-agent checks.
|
|
34
|
+
- No format guessing.
|
|
35
|
+
|
|
36
|
+
The browser still decides which source to play.
|
|
37
|
+
|
|
38
|
+
## What it does
|
|
39
|
+
|
|
40
|
+
- Renders an empty `<video>`
|
|
41
|
+
- Observes it with IntersectionObserver
|
|
42
|
+
- Injects `<source>` elements when it enters the viewport
|
|
43
|
+
- Optionally pauses playback when it leaves
|
|
44
|
+
|
|
45
|
+
That’s it.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install lazyvid
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Requires React 18 or 19.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### Basic — just lazy load it
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
import { LazyVideo } from "lazyvid";
|
|
61
|
+
|
|
62
|
+
<LazyVideo
|
|
63
|
+
sources={[
|
|
64
|
+
{ src: "/promo.webm", type: "video/webm" },
|
|
65
|
+
{ src: "/promo.mp4", type: "video/mp4" },
|
|
66
|
+
]}
|
|
67
|
+
controls
|
|
68
|
+
/>;
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Sources are listed in priority order. The browser takes the first format it supports — put your lightest format first.
|
|
72
|
+
|
|
73
|
+
### Background video that pauses off-screen
|
|
74
|
+
|
|
75
|
+
```tsx
|
|
76
|
+
<LazyVideo
|
|
77
|
+
sources={[
|
|
78
|
+
{ src: "/bg.webm", type: "video/webm" },
|
|
79
|
+
{ src: "/bg.mp4", type: "video/mp4" },
|
|
80
|
+
]}
|
|
81
|
+
autoPlay
|
|
82
|
+
muted
|
|
83
|
+
loop
|
|
84
|
+
pauseOnLeave
|
|
85
|
+
style={{ width: "100%", objectFit: "cover" }}
|
|
86
|
+
/>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
When the user scrolls away — video pauses. Scrolls back — resumes. No wasted CPU on invisible playback.
|
|
90
|
+
|
|
91
|
+
### With poster and loading callback
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
const [ready, setReady] = useState(false);
|
|
95
|
+
|
|
96
|
+
<div style={{ position: "relative" }}>
|
|
97
|
+
{!ready && <div className="skeleton" />}
|
|
98
|
+
<LazyVideo
|
|
99
|
+
sources={[{ src: "/intro.mp4", type: "video/mp4" }]}
|
|
100
|
+
poster="/intro-thumb.jpg"
|
|
101
|
+
controls
|
|
102
|
+
onLoaded={() => setReady(true)}
|
|
103
|
+
/>
|
|
104
|
+
</div>;
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Start loading earlier
|
|
108
|
+
|
|
109
|
+
By default, loading starts 200px before the video is visible. Want more buffer?
|
|
110
|
+
|
|
111
|
+
```tsx
|
|
112
|
+
<LazyVideo
|
|
113
|
+
sources={[{ src: "/hero.mp4", type: "video/mp4" }]}
|
|
114
|
+
rootMargin="500px"
|
|
115
|
+
muted
|
|
116
|
+
autoPlay
|
|
117
|
+
/>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Props
|
|
121
|
+
|
|
122
|
+
| Prop | Type | Default | Description |
|
|
123
|
+
| -------------- | --------------------- | --------- | ----------------------------------------------- |
|
|
124
|
+
| `sources` | `VideoSource[]` | required | `{ src, type }` objects in priority order |
|
|
125
|
+
| `threshold` | `number` | `0` | How much of the element should be visible (0–1) |
|
|
126
|
+
| `rootMargin` | `string` | `"200px"` | Start loading before the element is in view |
|
|
127
|
+
| `pauseOnLeave` | `boolean` | `false` | Pause when out of viewport, resume when back |
|
|
128
|
+
| `onLoaded` | `() => void` | — | Fires when sources are injected |
|
|
129
|
+
| `...rest` | `VideoHTMLAttributes` | — | Any native `<video>` attribute works |
|
|
130
|
+
|
|
131
|
+
## How it works
|
|
132
|
+
|
|
133
|
+
1. Renders an empty `<video>` — nothing downloads
|
|
134
|
+
2. IntersectionObserver watches it (with `rootMargin` for early preloading)
|
|
135
|
+
3. Element enters viewport → `<source>` tags injected → browser picks best format → loading starts
|
|
136
|
+
4. Observer disconnects (one-time job)
|
|
137
|
+
5. If `pauseOnLeave` is on, a second observer manages play/pause on scroll
|
|
138
|
+
|
|
139
|
+
## Types
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
import { LazyVideo, type VideoSource } from "lazyvid";
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
type VideoSource = {
|
|
4
|
+
type: string;
|
|
5
|
+
src: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type LazyVideoProps = {
|
|
9
|
+
sources: VideoSource[];
|
|
10
|
+
threshold?: number;
|
|
11
|
+
rootMargin?: string;
|
|
12
|
+
pauseOnLeave?: boolean;
|
|
13
|
+
onLoaded?: () => void;
|
|
14
|
+
} & React.VideoHTMLAttributes<HTMLVideoElement>;
|
|
15
|
+
declare const LazyVideo: ({ sources, threshold, rootMargin, pauseOnLeave, onLoaded, ...videoProps }: LazyVideoProps) => react_jsx_runtime.JSX.Element;
|
|
16
|
+
|
|
17
|
+
export { LazyVideo, type VideoSource };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as react_jsx_runtime from 'react/jsx-runtime';
|
|
2
|
+
|
|
3
|
+
type VideoSource = {
|
|
4
|
+
type: string;
|
|
5
|
+
src: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
type LazyVideoProps = {
|
|
9
|
+
sources: VideoSource[];
|
|
10
|
+
threshold?: number;
|
|
11
|
+
rootMargin?: string;
|
|
12
|
+
pauseOnLeave?: boolean;
|
|
13
|
+
onLoaded?: () => void;
|
|
14
|
+
} & React.VideoHTMLAttributes<HTMLVideoElement>;
|
|
15
|
+
declare const LazyVideo: ({ sources, threshold, rootMargin, pauseOnLeave, onLoaded, ...videoProps }: LazyVideoProps) => react_jsx_runtime.JSX.Element;
|
|
16
|
+
|
|
17
|
+
export { LazyVideo, type VideoSource };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
LazyVideo: () => LazyVideo
|
|
24
|
+
});
|
|
25
|
+
module.exports = __toCommonJS(index_exports);
|
|
26
|
+
|
|
27
|
+
// src/react/LazyVideo.tsx
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
|
|
30
|
+
// src/core/createLazyObserver.ts
|
|
31
|
+
var createLazyObserver = ({
|
|
32
|
+
element,
|
|
33
|
+
onIntersect,
|
|
34
|
+
options
|
|
35
|
+
}) => {
|
|
36
|
+
if (typeof window === "undefined") return;
|
|
37
|
+
const observer = new IntersectionObserver((entries) => {
|
|
38
|
+
if (entries[0].isIntersecting) {
|
|
39
|
+
onIntersect();
|
|
40
|
+
observer.unobserve(element);
|
|
41
|
+
}
|
|
42
|
+
}, options);
|
|
43
|
+
observer.observe(element);
|
|
44
|
+
return () => observer.disconnect();
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// src/core/createPlaybackObserver.ts
|
|
48
|
+
var createPlaybackObserver = ({
|
|
49
|
+
video,
|
|
50
|
+
options
|
|
51
|
+
}) => {
|
|
52
|
+
if (typeof window === "undefined") return;
|
|
53
|
+
const observer = new IntersectionObserver((entries) => {
|
|
54
|
+
if (entries[0].isIntersecting) {
|
|
55
|
+
video.play().catch(() => {
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
video.pause();
|
|
59
|
+
}
|
|
60
|
+
}, options);
|
|
61
|
+
observer.observe(video);
|
|
62
|
+
return () => observer.disconnect();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// src/react/LazyVideo.tsx
|
|
66
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
67
|
+
var LazyVideo = ({
|
|
68
|
+
sources,
|
|
69
|
+
threshold = 0,
|
|
70
|
+
rootMargin = "200px",
|
|
71
|
+
pauseOnLeave = false,
|
|
72
|
+
onLoaded,
|
|
73
|
+
...videoProps
|
|
74
|
+
}) => {
|
|
75
|
+
const videoRef = (0, import_react.useRef)(null);
|
|
76
|
+
const sourcesRef = (0, import_react.useRef)(sources);
|
|
77
|
+
const onLoadedRef = (0, import_react.useRef)(onLoaded);
|
|
78
|
+
const [loaded, setLoaded] = (0, import_react.useState)(false);
|
|
79
|
+
sourcesRef.current = sources;
|
|
80
|
+
onLoadedRef.current = onLoaded;
|
|
81
|
+
(0, import_react.useEffect)(() => {
|
|
82
|
+
const elem = videoRef.current;
|
|
83
|
+
if (!elem) return;
|
|
84
|
+
return createLazyObserver({
|
|
85
|
+
element: elem,
|
|
86
|
+
options: { threshold, rootMargin },
|
|
87
|
+
onIntersect: () => {
|
|
88
|
+
if (sourcesRef.current.length === 0) return;
|
|
89
|
+
sourcesRef.current.forEach(({ src, type }) => {
|
|
90
|
+
const source = document.createElement("source");
|
|
91
|
+
source.src = src;
|
|
92
|
+
source.type = type;
|
|
93
|
+
elem.appendChild(source);
|
|
94
|
+
});
|
|
95
|
+
elem.load();
|
|
96
|
+
setLoaded(true);
|
|
97
|
+
onLoadedRef.current?.();
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}, [threshold, rootMargin]);
|
|
101
|
+
(0, import_react.useEffect)(() => {
|
|
102
|
+
const video = videoRef.current;
|
|
103
|
+
if (!video || !loaded || !pauseOnLeave) return;
|
|
104
|
+
return createPlaybackObserver({ video });
|
|
105
|
+
}, [loaded, pauseOnLeave]);
|
|
106
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("video", { ref: videoRef, ...videoProps });
|
|
107
|
+
};
|
|
108
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
109
|
+
0 && (module.exports = {
|
|
110
|
+
LazyVideo
|
|
111
|
+
});
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// src/react/LazyVideo.tsx
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
// src/core/createLazyObserver.ts
|
|
5
|
+
var createLazyObserver = ({
|
|
6
|
+
element,
|
|
7
|
+
onIntersect,
|
|
8
|
+
options
|
|
9
|
+
}) => {
|
|
10
|
+
if (typeof window === "undefined") return;
|
|
11
|
+
const observer = new IntersectionObserver((entries) => {
|
|
12
|
+
if (entries[0].isIntersecting) {
|
|
13
|
+
onIntersect();
|
|
14
|
+
observer.unobserve(element);
|
|
15
|
+
}
|
|
16
|
+
}, options);
|
|
17
|
+
observer.observe(element);
|
|
18
|
+
return () => observer.disconnect();
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// src/core/createPlaybackObserver.ts
|
|
22
|
+
var createPlaybackObserver = ({
|
|
23
|
+
video,
|
|
24
|
+
options
|
|
25
|
+
}) => {
|
|
26
|
+
if (typeof window === "undefined") return;
|
|
27
|
+
const observer = new IntersectionObserver((entries) => {
|
|
28
|
+
if (entries[0].isIntersecting) {
|
|
29
|
+
video.play().catch(() => {
|
|
30
|
+
});
|
|
31
|
+
} else {
|
|
32
|
+
video.pause();
|
|
33
|
+
}
|
|
34
|
+
}, options);
|
|
35
|
+
observer.observe(video);
|
|
36
|
+
return () => observer.disconnect();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// src/react/LazyVideo.tsx
|
|
40
|
+
import { jsx } from "react/jsx-runtime";
|
|
41
|
+
var LazyVideo = ({
|
|
42
|
+
sources,
|
|
43
|
+
threshold = 0,
|
|
44
|
+
rootMargin = "200px",
|
|
45
|
+
pauseOnLeave = false,
|
|
46
|
+
onLoaded,
|
|
47
|
+
...videoProps
|
|
48
|
+
}) => {
|
|
49
|
+
const videoRef = useRef(null);
|
|
50
|
+
const sourcesRef = useRef(sources);
|
|
51
|
+
const onLoadedRef = useRef(onLoaded);
|
|
52
|
+
const [loaded, setLoaded] = useState(false);
|
|
53
|
+
sourcesRef.current = sources;
|
|
54
|
+
onLoadedRef.current = onLoaded;
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
const elem = videoRef.current;
|
|
57
|
+
if (!elem) return;
|
|
58
|
+
return createLazyObserver({
|
|
59
|
+
element: elem,
|
|
60
|
+
options: { threshold, rootMargin },
|
|
61
|
+
onIntersect: () => {
|
|
62
|
+
if (sourcesRef.current.length === 0) return;
|
|
63
|
+
sourcesRef.current.forEach(({ src, type }) => {
|
|
64
|
+
const source = document.createElement("source");
|
|
65
|
+
source.src = src;
|
|
66
|
+
source.type = type;
|
|
67
|
+
elem.appendChild(source);
|
|
68
|
+
});
|
|
69
|
+
elem.load();
|
|
70
|
+
setLoaded(true);
|
|
71
|
+
onLoadedRef.current?.();
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}, [threshold, rootMargin]);
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const video = videoRef.current;
|
|
77
|
+
if (!video || !loaded || !pauseOnLeave) return;
|
|
78
|
+
return createPlaybackObserver({ video });
|
|
79
|
+
}, [loaded, pauseOnLeave]);
|
|
80
|
+
return /* @__PURE__ */ jsx("video", { ref: videoRef, ...videoProps });
|
|
81
|
+
};
|
|
82
|
+
export {
|
|
83
|
+
LazyVideo
|
|
84
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lazyvid",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight lazy-loading video component with IntersectionObserver",
|
|
5
|
+
"main": "dist/index.cjs",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"require": "./dist/index.cjs"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm,cjs --dts",
|
|
20
|
+
"test": "vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
22
|
+
"format": "prettier --write .",
|
|
23
|
+
"format:check": "prettier --check ."
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"video",
|
|
27
|
+
"lazy-loading",
|
|
28
|
+
"intersection-observer",
|
|
29
|
+
"react"
|
|
30
|
+
],
|
|
31
|
+
"author": "",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"sideEffects": false,
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^18.0.0 || ^19.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@testing-library/jest-dom": "^6.9.1",
|
|
39
|
+
"@testing-library/react": "^16.3.2",
|
|
40
|
+
"@types/react": "^19.0.0",
|
|
41
|
+
"jsdom": "^28.1.0",
|
|
42
|
+
"prettier": "^3.8.1",
|
|
43
|
+
"react": "^19.0.0",
|
|
44
|
+
"tsup": "^8.5.1",
|
|
45
|
+
"typescript": "^5.9.3",
|
|
46
|
+
"vitest": "^4.0.18"
|
|
47
|
+
}
|
|
48
|
+
}
|