├── .circleci
└── config.yml
├── .gitignore
├── README.md
├── demo
└── index.html
├── package-lock.json
├── package.json
├── rollup.config.ts
├── src
└── viewportchecker.ts
└── tsconfig.json
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: 'circleci/node:latest'
6 | steps:
7 | - checkout
8 | - run:
9 | name: install
10 | command: npm install
11 | - run:
12 | name: release
13 | command: npm run semantic-release || true
14 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | viewport-checker
2 | =======================
3 |
4 | **Note: jQuery-viewport-checker has been rewritten and renamed to no longer require jQuery.**
5 |
6 | Little script that detects if an element is in the viewport and adds a class to it.
7 |
8 | > Starting V2.x.x this plugin no longer requires jQuery. Take a look at version [1.x.x](https://www.npmjs.com/package/jquery-viewport-checker) if you're still looking for the jQuery version.
9 |
10 | Installation
11 | ------------
12 |
13 | Distribution files are shipped with the npm package. Install the package and use it in your project:
14 |
15 | ```
16 | npm install --save viewport-checker
17 | ```
18 |
19 | Or use unpkg.com to directly include the distribution files in your site:
20 |
21 | ```html
22 |
23 | ```
24 |
25 | After including the script in your project you can construct a new instance by providing a querySelector. Make sure
26 | to call `attach()` after you're DOM is ready so your elements are actually checked.
27 | ```html
28 |
29 |
30 |
31 |
35 |
36 | ```
37 |
38 | Options
39 | -------
40 | `ViewportChecker` can be initialized with an additional argument representing the options. Available options are:
41 | ```javascript
42 | new ViewportChecker('.dummy', {
43 | classToAdd: 'visible', // Class to add to the elements when they are visible,
44 | classToAddForFullView: 'full-visible', // Class to add when an item is completely visible in the viewport
45 | classToRemove: 'invisible', // Class to remove before adding 'classToAdd' to the elements
46 | removeClassAfterAnimation: false, // Remove added classes after animation has finished
47 | offset: [100 OR 10%], // The offset of the elements (let them appear earlier or later). This can also be percentage based by adding a '%' at the end
48 | invertBottomOffset: true, // Add the offset as a negative number to the element's bottom
49 | repeat: false, // Add the possibility to remove the class if the elements are not visible
50 | callbackFunction: function(elem, action){}, // Callback to do after a class was added to an element. Action will return "add" or "remove", depending if the class was added or removed
51 | scrollHorizontal: false // Set to true if your website scrolls horizontal instead of vertical.
52 | });
53 | ```
54 |
55 | In addition to the global options you can also provide 'per element' options using `data-attributes`. These attributes will then override the globally set options.
56 |
57 | Available attributes are:
58 | ```html
59 | > classToAdd
60 | > classToRemove
61 | > Removes added classes after CSS3 animation has completed
62 | > offset
63 | > repeat
64 | > scrollHorizontal
65 | ```
66 |
67 | Use case
68 | --------
69 | The guys from web2feel have written a little tutorial with a great example of how you can use this script. Note that this tutorial was written for the original version (V1) which required jQuery. Although the API has changed it pretty much still shows what you can do with this plugin. You can check the [tutorial here](http://www.web2feel.com/tutorial-for-animated-scroll-loading-effects-with-animate-css-and-jquery/) and the [demo here](http://web2feel.com/freeby/scroll-effects/index.html).
70 |
--------------------------------------------------------------------------------
/demo/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
70 |
71 |
82 |
83 |
84 |
85 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "viewport-checker",
3 | "author": "Dirk Groenen",
4 | "main": "dist/viewportChecker.umd.js",
5 | "module": "dist/viewportChecker.es5.js",
6 | "files": [
7 | "dist"
8 | ],
9 | "version": "2.0.0",
10 | "homepage": "https://github.com/dirkgroenen/viewport-checker",
11 | "description": "Little script that detects if an element is in the viewport and adds a class to it.",
12 | "repository": "https://github.com/dirkgroenen/viewport-checker",
13 | "license": "MIT",
14 | "devDependencies": {
15 | "@types/node": "^13.11.0",
16 | "rollup": "^2.3.3",
17 | "rollup-plugin-commonjs": "^10.1.0",
18 | "rollup-plugin-json": "^4.0.0",
19 | "rollup-plugin-sourcemaps": "^0.5.0",
20 | "rollup-plugin-terser": "^5.3.0",
21 | "rollup-plugin-typescript2": "^0.27.0",
22 | "semantic-release": "^17.0.4",
23 | "typescript": "^3.8"
24 | },
25 | "scripts": {
26 | "clean": "rm -r ./dist",
27 | "build": "rollup -c rollup.config.ts"
28 | }
29 | }
--------------------------------------------------------------------------------
/rollup.config.ts:
--------------------------------------------------------------------------------
1 | import sourceMaps from 'rollup-plugin-sourcemaps'
2 | import typescript from 'rollup-plugin-typescript2'
3 |
4 | import { terser } from "rollup-plugin-terser";
5 | import pkg from './package.json';
6 |
7 | export default {
8 | input: `src/viewportchecker.ts`,
9 | output: [
10 | { file: pkg.main, name: 'ViewportChecker', format: 'umd', sourcemap: true },
11 | { file: pkg.module, format: 'es', sourcemap: true },
12 | ],
13 | watch: {
14 | include: 'src/**',
15 | },
16 | plugins: [
17 | // Compile TypeScript files
18 | typescript({ useTsconfigDeclarationDir: true }),
19 | // Resolve source maps to the original source
20 | sourceMaps(),
21 | terser()
22 | ],
23 | }
--------------------------------------------------------------------------------
/src/viewportchecker.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | interface Window {
3 | _VP_CHECKERS?: ViewportChecker[];
4 | }
5 | }
6 |
7 | export type Action = 'add' | 'remove';
8 |
9 | export interface ViewportCheckerOptions {
10 | classToAdd: string;
11 | classToRemove: string;
12 | classToAddForFullView: string;
13 | removeClassAfterAnimation: boolean;
14 | offset: number | string,
15 | repeat: boolean,
16 | invertBottomOffset: boolean,
17 | callbackFunction: ((elem: Element, action: Action) => void);
18 | scrollHorizontal: boolean;
19 | scrollBox: Window | string;
20 | }
21 |
22 | export type ViewportCheckerAttributeOptions = Partial>;
23 |
24 | interface BoxSize {
25 | height: number;
26 | width: number;
27 | }
28 |
29 | export default class ViewportChecker implements EventListenerObject {
30 |
31 | /**
32 | * Index on which the instance if registered in the global register
33 | */
34 | private _registerIndex: number | undefined;
35 |
36 | /**
37 | * User provided options, merged with default options
38 | */
39 | readonly options: ViewportCheckerOptions;
40 |
41 | /**
42 | * Cached list of element to use.
43 | */
44 | private elements: NodeListOf | undefined;
45 |
46 | /**
47 | * Size of the provided scrollBox
48 | */
49 | private boxSize: BoxSize = {
50 | height: 0,
51 | width: 0
52 | };
53 |
54 | constructor(readonly query: string, userOptions?: Partial) {
55 | // Merge user options with default options
56 | this.options = {
57 | classToAdd: 'visible',
58 | classToRemove: 'invisible',
59 | classToAddForFullView: 'full-visible',
60 | removeClassAfterAnimation: false,
61 | offset: 100,
62 | repeat: false,
63 | invertBottomOffset: true,
64 | callbackFunction: () => void 0,
65 | scrollHorizontal: false,
66 | scrollBox: window,
67 | ...userOptions
68 | };
69 | }
70 |
71 | handleEvent(evt: Event): void {
72 | switch (evt.type) {
73 | case 'scroll':
74 | this.check();
75 | break;
76 | case 'resize':
77 | this.recalculateBoxsize();
78 | this.check();
79 | break;
80 | }
81 | }
82 |
83 | /**
84 | * Query the document for elements and save them under elements
85 | */
86 | attach() {
87 | // Get elements and calculate box size
88 | this.elements = document.querySelectorAll(this.query);
89 | this.recalculateBoxsize();
90 |
91 | // Register on global event listeners
92 | this._registerIndex = registerGlobalInstance(this);
93 |
94 | if (!(this.options.scrollBox instanceof Window)) {
95 | const box = this.resolveScrollBox();
96 | box.addEventListener('scroll', this);
97 | }
98 |
99 | // Perform initial check
100 | this.check();
101 | }
102 |
103 | /**
104 | * Detach checker from elements.
105 | */
106 | detach() {
107 | if (this._registerIndex) {
108 | unregisterGlobalInstance(this._registerIndex);
109 | this._registerIndex = undefined;
110 |
111 | if (!(this.options.scrollBox instanceof Window)) {
112 | const box = this.resolveScrollBox();
113 | box.removeEventListener('scroll', this);
114 | }
115 | }
116 | }
117 |
118 | /**
119 | * Returns a reference to the defined scrollbox
120 | */
121 | private resolveScrollBox(): Window | HTMLElement {
122 | if (this.options.scrollBox instanceof Window) {
123 | return this.options.scrollBox;
124 | }
125 | const box = document.querySelector(this.options.scrollBox);
126 | if (!box) {
127 | throw new Error(`${this.options.scrollBox} does not resolve to an existing DOM Element`);
128 | }
129 | return box;
130 | }
131 |
132 | /**
133 | * Recalculate and set the box size
134 | */
135 | recalculateBoxsize() {
136 | this.boxSize = this.getBoxSize();
137 | }
138 |
139 | /**
140 | * Main method which checks the elements and applies the correct actions to it
141 | */
142 | check() {
143 | let viewportStart = 0;
144 | let viewportEnd = 0;
145 |
146 | // Set some vars to check with
147 | if (!this.options.scrollHorizontal) {
148 | viewportStart = Math.max(
149 | document.body.scrollTop,
150 | document.documentElement.scrollTop,
151 | window.scrollY
152 | );
153 | viewportEnd = (viewportStart + this.boxSize.height);
154 | }
155 | else {
156 | viewportStart = Math.max(
157 | document.body.scrollLeft,
158 | document.documentElement.scrollLeft,
159 | window.scrollX
160 | );
161 | viewportEnd = (viewportStart + this.boxSize.width);
162 | }
163 |
164 | // Loop through all given dom elements
165 | this.elements?.forEach(($obj: HTMLElement) => {
166 | const objOptions: ViewportCheckerOptions = { ...this.options };
167 |
168 | const attrOptionMap: { [key in keyof ViewportCheckerAttributeOptions]-?: string } = {
169 | classToAdd: 'vpAddClass',
170 | classToRemove: 'vpRemoveClass',
171 | classToAddForFullView: 'vpAddClassFullView',
172 | removeClassAfterAnimation: 'vpKeepAddClass',
173 | offset: 'vpOffset',
174 | repeat: 'vpRepeat',
175 | scrollHorizontal: 'vpScrollHorizontal',
176 | invertBottomOffset: 'vpInvertBottomOffset'
177 | };
178 |
179 | // Get any individual attribution data and override original
180 | // options.
181 | for (const opt in attrOptionMap) {
182 | const dataKey = attrOptionMap[opt as keyof typeof attrOptionMap];
183 | const val = $obj.dataset[dataKey];
184 | if (val) { (objOptions as any)[opt] = val; };
185 | }
186 |
187 | // If class already exists; quit
188 | if ($obj.dataset.vpAnimated && !objOptions.repeat) {
189 | return;
190 | }
191 |
192 | // Check if the offset is percentage based
193 | let objOffset: number;
194 | if (typeof objOptions.offset === 'string') {
195 | objOffset = objOptions.offset.includes('%') ? (parseInt(objOptions.offset) / 100) * this.boxSize.height : parseInt(objOptions.offset);
196 | }
197 | else if (typeof objOptions.offset === 'number') {
198 | objOffset = objOptions.offset;
199 | }
200 | else {
201 | throw new Error(`Provided objOffet '${objOptions.offset}' can't be parsed. Provide a percentage or absolute number`);
202 | }
203 |
204 | // Get the raw start and end positions
205 | let rawStart: number = (!objOptions.scrollHorizontal) ? $obj.getBoundingClientRect().top : $obj.getBoundingClientRect().left;
206 | let rawEnd: number = rawStart + ((!objOptions.scrollHorizontal) ? $obj.clientHeight : $obj.clientWidth);
207 |
208 | // Add the defined offset
209 | let elemStart = Math.round(rawStart) + objOffset;
210 | let elemEnd = elemStart + ((!objOptions.scrollHorizontal) ? $obj.clientHeight : $obj.clientWidth);
211 |
212 | if (objOptions.invertBottomOffset) {
213 | elemEnd -= (objOffset * 2);
214 | }
215 |
216 | // Add class if in viewport
217 | if ((elemStart < viewportEnd) && (elemEnd > viewportStart)) {
218 | // Remove class
219 | $obj.classList.remove(...objOptions.classToRemove.split(' '));
220 | $obj.classList.add(...objOptions.classToAdd.split(' '));
221 |
222 | // Do the callback function. Callback wil send the jQuery object as parameter
223 | objOptions.callbackFunction($obj, 'add');
224 |
225 | // Check if full element is in view
226 | if (rawEnd <= viewportEnd && rawStart >= viewportStart) {
227 | $obj.classList.add(...objOptions.classToAddForFullView.split(' '));
228 | }
229 | else {
230 | $obj.classList.remove(...objOptions.classToAddForFullView.split(' '));
231 | }
232 | // Set element as already animated
233 | $obj.dataset.vpAnimated = 'true';
234 |
235 | if (objOptions.removeClassAfterAnimation) {
236 | $obj.addEventListener('animationend', () => $obj.classList.remove(...objOptions.classToAdd.split(' ')), {
237 | once: true
238 | });
239 | }
240 |
241 | // Remove class if not in viewport and repeat is true
242 | } else if (objOptions.repeat && objOptions.classToAdd.split(' ').reduce((exists, cls) => exists || $obj.classList.contains(cls), false)) {
243 | $obj.classList.remove(...objOptions.classToAdd.split(' '));
244 | $obj.classList.remove(...objOptions.classToAddForFullView.split(' '));
245 |
246 | // Do the callback function.
247 | objOptions.callbackFunction($obj, "remove");
248 |
249 | // Remove already-animated-flag
250 | $obj.dataset.vpAnimated = undefined;
251 | }
252 | });
253 | }
254 |
255 | /**
256 | * Get box size of provided scrollBox
257 | */
258 | private getBoxSize(): BoxSize {
259 | const box = this.resolveScrollBox();
260 | return (box instanceof Window) ? { height: box.innerHeight, width: box.innerWidth } : { height: box.clientHeight, width: box.clientWidth };
261 | }
262 | }
263 |
264 | /**
265 | * Register the provided instance on the global window object
266 | * which allows us to reuse the existing event listeners.
267 | *
268 | * The returned index can be used to remove the registered instance
269 | * from the register
270 | */
271 | const registerGlobalInstance = (instance: ViewportChecker): number => {
272 | window._VP_CHECKERS = window._VP_CHECKERS || [];
273 | return window._VP_CHECKERS.push(instance);
274 | };
275 |
276 | /**
277 | * Removes an instance from the global register
278 | */
279 | const unregisterGlobalInstance = (index: number): void => {
280 | window._VP_CHECKERS = window._VP_CHECKERS || [];
281 | if (window._VP_CHECKERS[index]) {
282 | window._VP_CHECKERS.splice(index, 1);
283 | }
284 | };
285 |
286 | ((window: Window, document: Document) => {
287 | /**
288 | * Check elements of registered instances
289 | */
290 | const checkElements = () => {
291 | (window._VP_CHECKERS || []).forEach(i => i.check());
292 | };
293 |
294 | /**
295 | * Check elements of registered instances
296 | */
297 | const recalculateBoxsizes = () => {
298 | (window._VP_CHECKERS || []).forEach(i => i.recalculateBoxsize());
299 | };
300 |
301 | /**
302 | * Binding the correct event listener is still a tricky thing.
303 | * People have expierenced sloppy scrolling when both scroll and touch
304 | * events are added, but to make sure devices with both scroll and touch
305 | * are handled too we always have to add the window.scroll event
306 | *
307 | * @see https://github.com/dirkgroenen/jQuery-viewport-checker/issues/25
308 | * @see https://github.com/dirkgroenen/jQuery-viewport-checker/issues/27
309 | */
310 | if ('ontouchstart' in window || 'onmsgesturechange' in window) {
311 | // Device with touchscreen
312 | ['touchmove', 'MSPointerMove', 'pointermove'].forEach(e => document.addEventListener(e, checkElements));
313 | }
314 |
315 | // Always load on window load
316 | window.addEventListener('load', checkElements, { once: true });
317 |
318 | // Handle resizes
319 | window.addEventListener('resize', () => {
320 | recalculateBoxsizes();
321 | checkElements();
322 | });
323 | })(window, document);
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "incremental": true,
4 | "target": "es5",
5 | "module": "es2015",
6 | "moduleResolution": "node",
7 | "lib": [
8 | "DOM",
9 | "ES2015"
10 | ],
11 | "declaration": true,
12 | "sourceMap": true,
13 | "outDir": "./dist/lib",
14 | "declarationDir": "./dist/types",
15 | "strict": true,
16 | "forceConsistentCasingInFileNames": true,
17 | "resolveJsonModule": true,
18 | "esModuleInterop": true,
19 | "typeRoots": [
20 | "node_modules/@types"
21 | ]
22 | },
23 | "include": [
24 | "./src"
25 | ]
26 | }
--------------------------------------------------------------------------------