├── .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 |
86 |
87 |
Scroll down
88 |
89 |
element 1 (repeat=true, offset=100)
91 |
92 |
element 2 (repeat=false, offset=100)
94 |
95 |
element 3 (repeat=true, offset=100)
97 |
98 |
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 | } --------------------------------------------------------------------------------