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 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
@@ -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 };
@@ -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
+ }