├── .editorconfig ├── .eslintrc.js ├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .nova ├── Configuration.json └── Tasks │ └── Tasks.json ├── .prettierrc ├── LICENSE ├── README.md ├── docs ├── index.css ├── index.html ├── index.min.js └── main.js ├── package.json ├── pnpm-lock.yaml ├── src └── index.js └── vite.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = false 7 | indent_style = tab 8 | indent_size = 4 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: ['standard', 'plugin:react-hooks/recommended', 'prettier'], 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | }, 11 | rules: { 12 | 'space-before-function-paren': 'off', 13 | 'comma-dangle': ['error', 'always-multiline'], 14 | }, 15 | ignorePatterns: ['dist', 'node_modules', 'bundled'], 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | on: 3 | release: 4 | types: [created] 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v3 11 | 12 | - name: Setup Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 16 16 | registry-url: 'https://registry.npmjs.org' 17 | 18 | - name: Install pnpm 19 | uses: pnpm/action-setup@v2 20 | with: 21 | version: 7 22 | run_install: false 23 | 24 | - name: Install dependencies and build 25 | run: pnpm i --frozen-lockfile 26 | - name: Publish package on NPM 27 | run: npm publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | bundled 13 | dist-ssr 14 | *.local 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? -------------------------------------------------------------------------------- /.nova/Configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace.name" : "inview-detection" 3 | } 4 | -------------------------------------------------------------------------------- /.nova/Tasks/Tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "actions" : { 3 | "build" : { 4 | "enabled" : true, 5 | "script" : "npm run build" 6 | }, 7 | "clean" : { 8 | "enabled" : true, 9 | "script" : "npm run clean" 10 | }, 11 | "run" : { 12 | "enabled" : true, 13 | "script" : "npm run dev" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "endOfLine": "auto", 4 | "semi": false, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Code Resolution 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![InView Detection](https://coderesolution.com/misc/inview.jpg) 2 | 3 | # Inview Detection 4 | 5 | Inview Detection enables the creation of sequential animations based on in-view detection using custom `data-inview-*` attributes in the DOM. Powered by GSAP. 6 | 7 | [Walk-through video](https://cloud.cdrs.dev/h6BvSP9P) 8 | 9 | NPM Version 10 | Licence 11 | Bundle file size 12 | Bundle file size (gzip) 13 | 14 | ## Features 15 | 16 | - Standalone elements 17 | - Scoping, bind elements to parent 18 | - Custom queuing and animations 19 | - Trigger callbacks 20 | - Conditional classes 21 | - Repeatable 22 | - Target specific screen sizes 23 | - Debugging mode 24 | - Lightweight (1.63 kB gzipped) 25 | 26 | ## Dependencies 27 | 28 | Ensure the following dependencies are installed and properly registered: 29 | 30 | - [GSAP v3](https://greensock.com/gsap/) 31 | - [GSAP ScrollTrigger](https://greensock.com/scrolltrigger/) 32 | 33 | ## Quick start 34 | 35 | Inview Detection requires the GSAP library as well as ScrollTrigger to function correctly. Ensure both are included **before** Inview Detection and registered within the instantiation. 36 | 37 | ### Install from NPM 38 | 39 | ```js 40 | import { gsap } from 'gsap' 41 | import { ScrollTrigger } from 'gsap/ScrollTrigger' 42 | import InviewDetection from 'inview-detection' 43 | 44 | // Register ScrollTrigger plugin 45 | gsap.registerPlugin(ScrollTrigger) 46 | 47 | // Initialise InviewDetection and pass in gsap and ScrollTrigger 48 | const inview = new InviewDetection( 49 | { 50 | /* options */ 51 | }, 52 | gsap, 53 | ScrollTrigger, 54 | ) 55 | ``` 56 | 57 | ### Delayed start (recommended) 58 | 59 | To initialise the module without starting it immediately, set `autoStart` option to `false`. 60 | 61 | ```js 62 | // Create instance but do not start automatically 63 | const inview = new InviewDetection( 64 | { 65 | autoStart: false, 66 | }, 67 | gsap, 68 | ScrollTrigger, 69 | ) 70 | 71 | // Start it when you are ready 72 | document.addEventListener('DOMContentLoaded', () => { 73 | inview.start() 74 | }) 75 | ``` 76 | 77 | With `autoStart` disabled, for extra clarity `inview.register` can be used to register `gsap` and `ScrollTrigger` outside of the instantiation. 78 | 79 | ```js 80 | // Standard 81 | const inview = new InviewDetection( 82 | { 83 | autoStart: false, 84 | }, 85 | gsap, 86 | ScrollTrigger, 87 | ) 88 | ``` 89 | 90 | Optionally may be replaced with: 91 | 92 | ```js 93 | const inview = new InviewDetection({ 94 | autoStart: false, 95 | }) 96 | 97 | // Register gsap and ScrollTrigger separately 98 | inview.register(gsap, ScrollTrigger) 99 | ``` 100 | 101 | ### Install from CDN 102 | 103 | If you prefer to use a CDN, here is an example: 104 | 105 | ```html 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 126 | ``` 127 | 128 | ## Options 129 | 130 | You can configure Inview Detection via options: 131 | 132 | ```js 133 | const inview = new InviewDetection( 134 | { 135 | elements: '[data-inview]', 136 | autoStart: true, 137 | screen: '(min-width: 1025px)', 138 | duration: 1, 139 | delay: 0.1, 140 | start: 'top 90%', 141 | ease: 'power4', 142 | stagger: 0.08, 143 | animationFrom: { 144 | opacity: 0, 145 | 'will-change': 'transform', 146 | y: 20, 147 | }, 148 | animationTo: { 149 | opacity: 1, 150 | y: 0, 151 | }, 152 | inviewClass: 'is-inview', 153 | viewedClass: 'has-viewed', 154 | debug: false, 155 | }, 156 | gsap, 157 | ScrollTrigger, 158 | ) 159 | ``` 160 | 161 | All options: 162 | 163 | | Name | Type | Default | Description | 164 | | :-------------- | :----: | :-----------------------: | :----------------------------------------------------------------------------------------------------------------------------------- | 165 | | `elements` | `str` | `[data-inview]` | What elements to apply inview animations to. | 166 | | `autoStart` | `bool` | `true` | Whether to start immediately. Set to `false` for a delayed start (recommended). | 167 | | `screen` | `str` | `'(min-width: 1025px)'` | Specify media query rules for animations. This can be overwritten on a per animation-basis. Set to `all` to remove queries entirely. | 168 | | `duration` | `num` | `1` | Duration of each animation. | 169 | | `delay` | `num` | `.1` | Delay before animation starts. | 170 | | `start` | `str` | `top 90%` | ScrollTrigger's starting position for the animation. | 171 | | `ease` | `str` | `power4` | Easing of animation ([help](https://greensock.com/docs/Easing)). | 172 | | `stagger` | `num` | `0.08` | Time between each animation in the sequence. | 173 | | `animationFrom` | `json` | `{"opacity": 0, "y": 20}` | The initial state of each animation. | 174 | | `animationTo` | `json` | `{"opacity": 1, "y": 0}` | The final state of each animation. | 175 | | `inviewClass` | `str` | `'is-inview'` | The class that is temporarily assigned to elements when they are within the viewport. | 176 | | `viewedClass` | `str` | `'has-viewed'` | The class that is permanently assigned to elements when they have been within the viewport. | 177 | | `debug` | `bool` | `false` | Set debug mode to all instances. Enables markers and console logs. | 178 | 179 | ## Attributes 180 | 181 | Apply any of the following data attributes in conjunction with `[data-inview]` to enable custom animations. 182 | 183 | - [scope](#scope) 184 | - [child](#child) 185 | - [debug](#debug) 186 | - [order](#order) 187 | - [repeat](#repeat) 188 | - [from/to](#fromto) 189 | - [start](#start) 190 | - [end](#end) 191 | - [screen](#screen) 192 | - [call](#call) 193 | 194 | ### Scope 195 | 196 | Attribute: `data-inview-scope` 197 | Type: `string` 198 | 199 | Specify the scope of nested elements using wildcards like `*`, `> *` or selectors `.class, #id`. 200 | 201 | ```html 202 |
203 | 204 |
205 | ``` 206 | 207 | ### Child 208 | 209 | Attribute: `data-inview-child` 210 | 211 | Apply attribute to elements that should animate when parent comes into view. The parent must have `[data-inview]` and `[data-inview-scope]` attributes. 212 | 213 | ```html 214 |
215 |
Child 1
216 |
Child 2
217 |
218 | ``` 219 | 220 | ### Debug 221 | 222 | Attribute: `data-inview-debug` 223 | 224 | Enable debugging markers and logs for animations. 225 | 226 | ```html 227 |
228 | ``` 229 | 230 | ### Order 231 | 232 | Attribute: `data-inview-order` 233 | Type: `number` 234 | 235 | Specify the order of animation for elements within a scope. 236 | 237 | ```html 238 |
239 |
First
240 |
Second
241 |
242 | ``` 243 | 244 | ### Repeat 245 | 246 | Attribute: `data-inview-repeat` 247 | 248 | Allow animations to re-trigger when elements re-enter the viewport. 249 | 250 | ```html 251 |
252 | ``` 253 | 254 | ### From / To 255 | 256 | Attributes: `data-inview-from`, `data-inview-to` 257 | Type: `json` 258 | 259 | Specify custom `gsap.from()` and `gsap.to()` properties for animations. 260 | 261 | ```html 262 |
263 | Custom Animation 264 |
265 | ``` 266 | 267 | ### Start 268 | 269 | Attribute: `data-inview-start` 270 | Default: `'top bottom'` or `'top top'` if (sticky)[#sticky] is set 271 | Type: `str` 272 | 273 | Adjust the point when the animation begins. 274 | 275 | The first value refers to the trigger and the second value refers to the viewport. Refer GSAP ScrollTrigger documentation for further information. 276 | 277 | ```html 278 |
279 | 280 |
281 | 282 |
283 | 284 |
285 | ``` 286 | 287 | ### End 288 | 289 | Attribute: `data-inview-end` 290 | Default: `'bottom top'` or `bottom bottom` if (sticky)[#sticky] is set 291 | Type: `str` 292 | 293 | Adjust the point when the animation ends. See [preset](#preset) for automatic detection with offsets. 294 | 295 | The first value refers to the trigger and the second value refers to the viewport. Refer GSAP ScrollTrigger documentation for further information. 296 | 297 | ```html 298 |
299 | 300 |
301 | 302 |
303 | 304 |
305 | ``` 306 | 307 | ### Screen 308 | 309 | Attribute: `data-inview-screen` 310 | Default: `'(min-width: 1025px)'` (configurable globally via [options](#options)) 311 | Type: `str` 312 | 313 | Set screen size conditions for the animation. For example: animate on desktop and not mobile, or vice-versa. Expects media queries like `(min-width: 500px)` or `(min-width: 768px) and (max-width: 1000px)` etc. Set to `all` to animate on all screen sizes. 314 | 315 | ### Call 316 | 317 | Attribute: `data-inview-call` 318 | Default: Not set 319 | Type: `str` 320 | 321 | Fire custom JavaScript events when elements enter and leave the viewport. 322 | Data such as `target`, `direction` (up/down) and `when` (enter, re-enter, leave and leave-again) are accessible within an event listener. 323 | 324 | ```html 325 |
Trigger
326 | ``` 327 | 328 | ```js 329 | window.addEventListener('inviewEvent', (e) => { 330 | const { target, direction, when } = e.detail 331 | console.log(`target: ${target}`, `direction: ${direction}`, `when: ${when}`) 332 | }) 333 | ``` 334 | 335 | ## Methods 336 | 337 | ### Start 338 | 339 | Start Inview Detection to initialise animations, useful when `autoStart` is set to `false`. 340 | 341 | ```js 342 | inview.start() 343 | ``` 344 | 345 | ### Register GSAP 346 | 347 | Register `gsap` and `ScrollTrigger` dependencies with InviewDetection. 348 | 349 | ```js 350 | inview.register(gsap, ScrollTrigger) 351 | ``` 352 | 353 | ### Refresh 354 | 355 | Update ScrollTrigger calculations, useful if the page height changes. 356 | 357 | ```js 358 | inview.refresh() 359 | ``` 360 | 361 | ### Stop and fetch 362 | 363 | Stop all animations and remove the ScrollTrigger instances. 364 | 365 | ```js 366 | /* Stop all animations */ 367 | inview.stop() 368 | 369 | /* Stop a specific animation */ 370 | const element = document.querySelector('#myElement') 371 | const trigger = inview.fetch(element) 372 | inview.stop(trigger) 373 | ``` 374 | 375 | ### Restart 376 | 377 | Stop and restart animations. 378 | 379 | ```js 380 | inview.restart() 381 | ``` 382 | 383 | ## Classes 384 | 385 | | Class | Application | 386 | | :----------- | :----------------------------------------------------------- | 387 | | `is-inview` | Temporarily assigned to elements when they are in view. | 388 | | `has-viewed` | Permanently assigned to element when they have been in view. | 389 | 390 | ## Events 391 | 392 | ### Enter/Leave the viewport 393 | 394 | Detect when elements enter or leave the viewport. 395 | 396 | ```js 397 | inview.on('onEnter', (element) => { 398 | console.log('Entering view:', element) 399 | }) 400 | inview.on('onLeave', (element) => { 401 | console.log('Leaving view:', element) 402 | }) 403 | inview.on('onEnterBack', (element) => { 404 | console.log('Re-entering view:', element) 405 | }) 406 | inview.on('onLeaveBack', (element) => { 407 | console.log('Leaving view again:', element) 408 | }) 409 | ``` 410 | 411 | ### Refresh 412 | 413 | Detect when the `inview.refresh()` method is fired. 414 | 415 | ```js 416 | inview.on('refresh', () => { 417 | console.log('Refreshed') 418 | }) 419 | ``` 420 | 421 | ### Stop 422 | 423 | Detect when the `inview.stop()` method is fired. 424 | 425 | ```js 426 | inview.on('stop', (target) => { 427 | console.log('Stopped', target) 428 | }) 429 | ``` 430 | 431 | ### Restart 432 | 433 | Detect when the `inview.restart()` method is fired. 434 | 435 | ```js 436 | inview.on('restart', () => { 437 | console.log('Restarted') 438 | }) 439 | ``` 440 | 441 | ## Examples of use 442 | 443 | - [Code Resolution](https://coderesolution.com) 444 | - [Bay Harbor Towers](https://bayharbortowers.com) 445 | - [PAL Aerospace](https://palaerospace.com/) 446 | - [Enumera Molecular](https://enumeramolecular.com) 447 | - [ONE38](https://liveone38.com/) 448 | - [Stairwell](https://stairwell.com) 449 | - [Divino](https://divinoharrogate.co.uk) 450 | - [US Foot & Ankle Specialists](https://us-fas.com) 451 | 452 | ## License 453 | 454 | [The MIT License (MIT)](LICENSE) 455 | -------------------------------------------------------------------------------- /docs/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * HTML document & body 3 | */ 4 | html, body { 5 | background: #012 url("data:image/svg+xml;charset=utf-8,%3Csvg width='8' height='8' viewBox='0 0 6 6' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M5 0h1L0 6V5zm1 5v1H5z' fill='%23ffffff' fill-opacity='.075' fill-rule='evenodd'/%3E%3C/svg%3E") repeat top left / 8px; 6 | background-attachment: fixed; 7 | color: #fff; 8 | font-family: -apple-system, linkMacSystemFont, Helvetica Neue, BlinkMacSystemFont, Segoe UI, Roboto, Roboto, Arial, sans-serif; 9 | font-size: 16px; 10 | font-weight: 400; 11 | line-height: 1em; 12 | max-width: 100%; 13 | min-height: 100%; 14 | min-width: 320px; 15 | overflow-x: hidden; 16 | width: 100%; 17 | 18 | /* Font smoothing */ 19 | text-rendering: optimizeLegibility; 20 | -webkit-font-smoothing: antialiased; 21 | -webkit-text-stroke-width: 0.2px; 22 | -moz-osx-font-smoothing: grayscale; 23 | 24 | } 25 | 26 | html { 27 | scroll-behavior: initial; 28 | min-height: 100%; 29 | -ms-overflow-style: scrollbar; 30 | -webkit-tap-highlight-color: rgba(0,0,0,0); 31 | 32 | &.is-loading, 33 | &.is-leaving { 34 | cursor: wait; 35 | } 36 | 37 | } 38 | 39 | 40 | /** 41 | * Lenis 42 | */ 43 | html.lenis { 44 | height: auto; 45 | 46 | &.lenis-smooth { scroll-behavior: auto; } 47 | 48 | &.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; } 49 | 50 | &.lenis-stopped { overflow: hidden; } 51 | 52 | &.lenis-scrolling iframe { pointer-events: none; } 53 | 54 | } 55 | 56 | 57 | /*** 58 | The new CSS reset - version 1.7.3 (last updated 7.8.2022) 59 | GitHub page: https://github.com/elad2412/the-new-css-reset 60 | ***/ 61 | 62 | /* 63 | Remove all the styles of the "User-Agent-Stylesheet", except for the 'display' property 64 | - The "symbol *" part is to solve Firefox SVG sprite bug 65 | */ 66 | *:where(:not(html, iframe, canvas, img, svg, video, audio, pre):not(svg *, symbol *)) { 67 | all: unset; 68 | display: revert; 69 | } 70 | 71 | /* Preferred box-sizing value */ 72 | *, 73 | *::before, 74 | *::after { 75 | box-sizing: border-box; 76 | } 77 | 78 | /* Reapply the pointer cursor for anchor tags */ 79 | a, button { 80 | cursor: revert; 81 | } 82 | 83 | /* Remove list styles (bullets/numbers) */ 84 | ol, ul, menu { 85 | list-style: none; 86 | } 87 | 88 | /* For images to not be able to exceed their container */ 89 | img { 90 | max-width: 100%; 91 | } 92 | 93 | /* removes spacing between cells in tables */ 94 | table { 95 | border-collapse: collapse; 96 | } 97 | 98 | /* Safari - solving issue when using user-select:none on the text input doesn't working */ 99 | input, textarea { 100 | -webkit-user-select: auto; 101 | } 102 | 103 | /* revert the 'white-space' property for textarea elements on Safari */ 104 | textarea { 105 | white-space: revert; 106 | } 107 | 108 | /* minimum style to allow to style meter element */ 109 | meter { 110 | -webkit-appearance: revert; 111 | appearance: revert; 112 | } 113 | 114 | /* reset default text opacity of input placeholder */ 115 | ::placeholder { 116 | color: unset; 117 | } 118 | 119 | /* fix the feature of 'hidden' attribute. 120 | display:revert; revert to element instead of attribute */ 121 | :where([hidden]) { 122 | display: none; 123 | } 124 | 125 | /* revert for bug in Chromium browsers 126 | - fix for the content editable attribute will work properly. 127 | - webkit-user-select: auto; added for Safari in case of using user-select:none on wrapper element*/ 128 | :where([contenteditable]:not([contenteditable="false"])) { 129 | -moz-user-modify: read-write; 130 | -webkit-user-modify: read-write; 131 | overflow-wrap: break-word; 132 | -webkit-line-break: after-white-space; 133 | -webkit-user-select: auto; 134 | } 135 | 136 | /* apply back the draggable feature - exist only in Chromium and Safari */ 137 | :where([draggable="true"]) { 138 | -webkit-user-drag: element; 139 | } 140 | 141 | 142 | /** 143 | * Demo 144 | */ 145 | .section { 146 | align-items: center; 147 | border-bottom: 1px dashed rgba(255,255,255,.5); 148 | display: flex; 149 | font-size: 0; 150 | justify-content: center; 151 | height: 100vh; 152 | padding: 30px; 153 | position: relative; 154 | text-align: center; 155 | 156 | .section_label { 157 | background: rgba(255,255,255,.125); 158 | border-radius: 0 0 5px 0; 159 | left: 0; 160 | padding: 10px 15px; 161 | position: absolute; 162 | top: 0; 163 | text-align: left; 164 | } 165 | 166 | .label_heading { 167 | display: block; 168 | font-size: 16px; 169 | line-height: 1em; 170 | } 171 | 172 | .label_description { 173 | color: rgba(255,255,255,.6); 174 | display: block; 175 | font-size: 12px; 176 | line-height: 1em; 177 | margin-top: 5px; 178 | } 179 | 180 | .section_canvas { 181 | align-items: stretch; 182 | display: flex; 183 | flex-direction: column; 184 | justify-content: center; 185 | width: 100%; 186 | } 187 | 188 | .boxes { 189 | align-items: center; 190 | display: flex; 191 | flex-wrap: wrap; 192 | grid-gap: 20px; 193 | justify-content: center; 194 | margin: auto; 195 | width: 100%; 196 | } 197 | 198 | .box { 199 | align-items: center; 200 | background: #fff; 201 | border-radius: 4px; 202 | color: #000; 203 | display: inline-flex; 204 | font-size: 12px; 205 | justify-content: center; 206 | height: 140px; 207 | padding: 8px; 208 | text-align: center; 209 | width: 140px; 210 | } 211 | 212 | .text { 213 | align-items: center; 214 | display: flex; 215 | flex-wrap: wrap; 216 | grid-gap: 20px; 217 | justify-content: center; 218 | margin: auto; 219 | width: 100%; 220 | } 221 | 222 | .lines { 223 | display: block; 224 | font-size: 16px; 225 | line-height: 1.45em; 226 | margin: 0 auto; 227 | max-width: 500px; 228 | } 229 | 230 | .lines p { 231 | display: block; 232 | margin: .5em 0; 233 | } 234 | 235 | .buttons { 236 | align-items: center; 237 | column-gap: 20px; 238 | display: flex; 239 | justify-content: center; 240 | } 241 | 242 | .buttons button { 243 | background: #fff; 244 | border-radius: 4px; 245 | color: #000; 246 | font-size: 16px; 247 | line-height: 1em; 248 | padding: .75em 1em; 249 | } 250 | 251 | &.-sequence .boxes, 252 | &.-screens .boxes { 253 | max-width: calc(calc(140px * 3) + calc(20px * 3)); 254 | } 255 | 256 | &.-standalone .boxes { max-width: 140px; } 257 | 258 | &.-wip .section_label { background: rgba(255,99,71,.75); } 259 | 260 | } 261 | 262 | 263 | 264 | 265 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Demo - InviewDetection.js 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |
22 |
Sequential Animations
23 |
24 |

Animate multiple elements when their parent comes into view

25 |
26 |
27 |
28 |
29 |
1
30 |
2
31 |
3
32 |
4
33 |
5
34 |
6
35 |
36 |
37 |
38 | 39 |
40 |
41 |
Sequential Animation Stagger Adjustment
42 |
43 |

Adjust the time between each element animating in

44 |
45 |
46 |
47 |
48 |
1
49 |
2
50 |
3
51 |
4
52 |
5
53 |
6
54 |
55 |
56 |
57 | 58 |
59 |
60 |
Methods
61 |
62 |

Trigger methods with JavaScript

63 |
64 |
65 |
66 |
67 | 68 | 69 | 70 |
71 |
72 |
73 | 74 |
75 |
76 |
Standalone Animations
77 |
78 |

Animate a single element when comes into view

79 |
80 |
81 |
82 |
83 |
1
84 |
2
85 |
3
86 |
87 |
88 |
89 | 90 |
91 |
92 |
Delayed Animations
93 |
94 |

Adjust the delay before an element animates

95 |
96 |
97 |
98 |
99 |
1
100 |
2
101 |
3
102 |
103 |
104 |
105 | 106 |
107 |
108 |
Custom Ordering
109 |
110 |

Adjust the animations sequence

111 |
112 |
113 |
114 |
115 |
3
116 |
2
117 |
1
118 |
119 |
120 |
121 | 122 |
123 |
124 |
Custom Animations
125 |
126 |

Create custom animations

127 |
128 |
129 |
130 |
131 |
1
132 |
2
133 |
3
134 |
4
135 |
5
136 |
6
137 |
138 |
139 |
140 | 141 |
142 |
143 |
Repeating Animations
144 |
145 |

Reanimate elements when they re-enter the viewport

146 |
147 |
148 |
149 |
150 |
1
151 |
2
152 |
3
153 |
154 |
155 |
156 | 157 |
158 |
159 |
Screen Size
160 |
161 |

Only apply to specific screen-sizes

162 |
163 |
164 |
165 |
166 |
Desktop
167 |
Desktop
168 |
Desktop
169 |
Mobile
170 |
Mobile
171 |
Mobile
172 |
173 |
174 |
175 | 176 |
177 |
178 |
Scope
179 |
180 |

Define scope of elements

181 |
182 |
183 |
184 |
185 |
1
186 |
2
187 |
3
188 |
4
189 |
5
190 |
6
191 |
192 |
193 |
194 | 195 |
196 |
197 |
Custom Events
198 |
199 |

Call a custom event

200 |
201 |
202 |
203 |
204 |
1
205 |
206 |
207 |
208 | 209 |
210 |
211 |
Debugging
212 |
213 |

Useful developer consoling tools and markers

214 |
215 |
216 |
217 |
218 |

Open the console to see useful information.

219 |

Oh and check out those markers!

220 |
221 |
222 |
223 | 224 | 225 | 226 | -------------------------------------------------------------------------------- /docs/index.min.js: -------------------------------------------------------------------------------- 1 | !function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e||self)["inview-detection"]=t()}(this,function(){function e(){return e=Object.assign?Object.assign.bind():function(e){for(var t=1;t { 16 | lenis.raf(time * 1000) 17 | }) 18 | 19 | gsap.ticker.lagSmoothing(0) 20 | 21 | /* Initialise InviewDetection.js */ 22 | 23 | // const inview = new InviewDetection({ 24 | // autoStart: false, 25 | // },gsap, ScrollTrigger); 26 | 27 | const inview = new InviewDetection({ 28 | autoStart: false, 29 | debug: false, 30 | }) 31 | inview.register(gsap, ScrollTrigger) 32 | 33 | /* Buttons */ 34 | const oButtons = document.querySelectorAll('.js-button') 35 | 36 | oButtons.forEach((oButton) => { 37 | oButton.addEventListener('click', (e) => { 38 | e.preventDefault() 39 | 40 | switch (oButton.dataset.method) { 41 | case 'refresh': 42 | inview.refresh() 43 | break 44 | 45 | case 'stop': 46 | inview.stop() 47 | break 48 | 49 | case 'restart': 50 | inview.restart() 51 | break 52 | 53 | default: 54 | console.log('No method') 55 | } 56 | }) 57 | }) 58 | 59 | document.addEventListener('DOMContentLoaded', (event) => { 60 | inview.start() 61 | }) 62 | 63 | window.addEventListener('inviewEvent', (e) => { 64 | console.log('target', e.detail.target) 65 | }) 66 | 67 | // inview.on('onEnter', (element) => { 68 | // console.log('Entering top of view:', element) 69 | // }) 70 | // inview.on('onLeave', (element) => { 71 | // console.log('Leaving bottom of view:', element) 72 | // }) 73 | // inview.on('onEnterBack', (element) => { 74 | // console.log('Entering bottom of view:', element) 75 | // }) 76 | // inview.on('onLeaveBack', (element) => { 77 | // console.log('Leaving top of view:', element) 78 | // }) 79 | // inview.on('restart', () => { 80 | // console.log('Restarted') 81 | // }) 82 | // inview.on('stop', (target) => { 83 | // console.log('Stopped', target) 84 | // }) 85 | // inview.on('refresh', () => { 86 | // console.log('Refreshed') 87 | // }) 88 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "inview-detection", 3 | "version": "1.0.9", 4 | "description": "", 5 | "files": [ 6 | "dist/**/*", 7 | "bundled/**/*" 8 | ], 9 | "sideEffects": false, 10 | "source": "src/inview-detection.js", 11 | "main": "dist/inview-detection.js", 12 | "umd:main": "dist/inview-detection.umd.js", 13 | "module": "dist/inview-detection.mjs", 14 | "types": "dist/types/inview-detection.d.ts", 15 | "exports": { 16 | "require": "./dist/inview-detection.js", 17 | "default": "./dist/inview-detection.modern.mjs" 18 | }, 19 | "scripts": { 20 | "dev": "vite", 21 | "clean": "npm-run-all --parallel clean:bundled clean:dist", 22 | "clean:bundled": "rm -rf bundled", 23 | "clean:dist": "rm -rf dist", 24 | "prepublishOnly": "npm version patch", 25 | "postpublish": "git push --follow-tags", 26 | "preversion": "npm run build", 27 | "build": "npm-run-all --parallel clean build:dist build:bundle build:types", 28 | "build:types": "tsc --allowJs -d --emitDeclarationOnly --declarationDir ./dist/types --removeComments ./src/index.js", 29 | "build:dist": "microbundle build -i src/ --o ./dist", 30 | "build:bundle": "npm-run-all build:bundle-full build:bundle-min", 31 | "build:bundle-full": "microbundle build -i src/ --o ./bundled/index.js --no-sourcemap --no-pkg-main --external none --name inview-detection --format umd --no-compress", 32 | "build:bundle-min": "microbundle build -i src/ --o ./bundled/index.min.js --no-sourcemap --no-pkg-main --external none --name inview-detection --format umd" 33 | }, 34 | "repository": { 35 | "type": "git", 36 | "url": "git+https://github.com/coderesolution/inview-detection.git" 37 | }, 38 | "keywords": [], 39 | "author": "@coderesolution", 40 | "license": "ISC", 41 | "bugs": { 42 | "url": "https://github.com/coderesolution/inview-detection/issues" 43 | }, 44 | "homepage": "https://github.com/coderesolution/inview-detection#readme", 45 | "devDependencies": { 46 | "@size-limit/preset-small-lib": "^11.1.4", 47 | "eslint": "^9.3.0", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-config-standard": "^17.1.0", 50 | "eslint-plugin-import": "^2.29.1", 51 | "eslint-plugin-n": "^17.7.0", 52 | "eslint-plugin-promise": "^6.1.1", 53 | "eslint-plugin-react": "^7.34.1", 54 | "eslint-plugin-react-hooks": "^4.6.2", 55 | "microbundle": "^0.15.1", 56 | "npm-run-all": "^4.1.5", 57 | "prettier": "^3.2.5", 58 | "typescript": "^5.4.5", 59 | "vite": "^5.2.11" 60 | }, 61 | "size-limit": [ 62 | { 63 | "limit": "3 kB", 64 | "path": "dist/index.js" 65 | }, 66 | { 67 | "limit": "2 kB", 68 | "path": "dist/index.mjs" 69 | } 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Written by Elliott Mangham at Code Resolution. Maintained by Code Resolution. 3 | * made@coderesolution.com 4 | */ 5 | export default class InviewDetection { 6 | constructor(options = {}, gsap = null, ScrollTrigger = null) { 7 | // Set dependencies 8 | this._gsap = gsap 9 | this._ScrollTrigger = ScrollTrigger 10 | 11 | // Define default options 12 | this.defaultOptions = { 13 | elements: '[data-inview]', 14 | duration: 1, 15 | delay: 0.1, 16 | start: 'top 90%', 17 | ease: 'power4', 18 | stagger: 0.08, 19 | animationFrom: { 20 | opacity: 0, 21 | 'will-change': 'transform', 22 | y: 20, 23 | }, 24 | animationTo: { 25 | opacity: 1, 26 | y: 0, 27 | }, 28 | screen: '(min-width: 1025px)', 29 | autoStart: true, 30 | inviewClass: 'is-inview', 31 | viewedClass: 'has-viewed', 32 | debug: false, 33 | } 34 | 35 | // Merge default options with provided options 36 | this.options = { ...this.defaultOptions, ...options } 37 | 38 | // Store ScrollTrigger instances 39 | this.triggers = [] 40 | 41 | // Store all animated elements 42 | this.animatedElementsList = [] 43 | 44 | // Store event listeners 45 | this.eventListeners = {} 46 | 47 | // Start by default if set 48 | if (this.getOption('autoStart')) { 49 | this.init() 50 | } 51 | } 52 | 53 | // Register GSAP and plugins 54 | register(gsap, ScrollTrigger) { 55 | this._gsap = gsap 56 | this._ScrollTrigger = ScrollTrigger 57 | } 58 | 59 | // Function to get a specific option 60 | getOption(optionName) { 61 | return this.options[optionName] 62 | } 63 | 64 | // Initialisation function 65 | init() { 66 | // Check if gsap is registered 67 | if (this._gsap === null || this._gsap === undefined) { 68 | console.log('GSAP is not registered. Exiting') 69 | return 70 | } 71 | 72 | // Check if ScrollTrigger is registered 73 | if (this._ScrollTrigger === null || this._ScrollTrigger === undefined) { 74 | console.log('ScrollTrigger is not registered. Exiting') 75 | return 76 | } 77 | 78 | try { 79 | // Convert elements to an array and loop through each 80 | this._gsap.utils.toArray(this.getOption('elements')).forEach((parent, index) => { 81 | // Define array to hold animated elements 82 | let animatedElementsList = [] 83 | 84 | // If the parent doesn't have 'data-inview-scope' attribute, 85 | // add it to the animated elements 86 | // Otherwise, add scoped and child elements 87 | if (!parent.hasAttribute('data-inview-scope')) { 88 | animatedElementsList.push({ el: parent, order: parent.dataset.inviewOrder }) 89 | } else { 90 | this.addScopedElements(parent, animatedElementsList) 91 | this.addChildElements(parent, animatedElementsList) 92 | } 93 | 94 | // Order the animated elements based on their 'order' property 95 | this.sortAnimatedElements(animatedElementsList) 96 | 97 | // Animate the elements 98 | this.animateElements(parent, animatedElementsList, index) 99 | }) 100 | } catch (error) { 101 | // Catch and log any errors 102 | console.error('Error initialising InviewDetection:', error) 103 | } 104 | } 105 | 106 | // Function to register event listeners 107 | on(eventName, listener) { 108 | if (!this.eventListeners[eventName]) { 109 | this.eventListeners[eventName] = [] 110 | } 111 | this.eventListeners[eventName].push(listener) 112 | } 113 | 114 | // Function to emit events 115 | emit(eventName, element) { 116 | const eventListeners = this.eventListeners[eventName] 117 | if (eventListeners) { 118 | eventListeners.forEach((listener) => { 119 | listener(element) 120 | }) 121 | } 122 | } 123 | 124 | // Function to load and initialize the class 125 | start() { 126 | // Initialize the class 127 | this.init() 128 | } 129 | 130 | // Function to add scoped elements to the animatedElementsList array 131 | addScopedElements(parent, animatedElementsList) { 132 | try { 133 | // If the parent has 'data-inview-scope' attribute, 134 | // add all elements defined in this attribute to the animatedElementsList array 135 | if (parent.dataset.inviewScope) { 136 | parent.querySelectorAll(':scope ' + parent.dataset.inviewScope).forEach((element) => { 137 | const order = parseFloat(element.dataset.inviewOrder) 138 | animatedElementsList.push({ el: element, order: order }) 139 | this.animatedElementsList.push(element) 140 | }) 141 | } 142 | } catch (error) { 143 | // Catch and log any errors 144 | console.error('Error adding scoped elements:', error) 145 | } 146 | } 147 | 148 | // Function to add child elements to the animatedElementsList array 149 | addChildElements(parent, animatedElementsList) { 150 | try { 151 | // Add all elements with 'data-inview-child' attribute to the animatedElementsList array 152 | parent.querySelectorAll(':scope [data-inview-child]').forEach((element) => { 153 | const order = parseFloat(element.dataset.inviewOrder) 154 | animatedElementsList.push({ el: element, order: order }) 155 | this.animatedElementsList.push(element) 156 | }) 157 | } catch (error) { 158 | // Catch and log any errors 159 | console.error('Error adding child elements:', error) 160 | } 161 | } 162 | 163 | // Function to find the closest parent with 'data-inview-order' attribute 164 | findClosestParentOrderAttr(element) { 165 | let parent = element.parentElement 166 | let ancestorsIndexed = 0 167 | let ancestorsLimit = 5 168 | 169 | // Iterate through parent elements up to ancestorsLimit 170 | while (parent && ancestorsIndexed <= ancestorsLimit) { 171 | if (parent.hasAttribute('data-inview-order')) { 172 | return parseFloat(parent.getAttribute('data-inview-order')) 173 | } 174 | parent = parent.parentElement 175 | ancestorsIndexed++ 176 | } 177 | 178 | if (element.hasAttribute('data-inview-order')) { 179 | const value = element.getAttribute('data-inview-order') 180 | return isNaN(+value) ? false : +value 181 | } 182 | 183 | return false 184 | } 185 | 186 | // Function to order animated elements based on their 'order' property 187 | sortAnimatedElements(animatedElementsList) { 188 | animatedElementsList.sort((a, b) => { 189 | return (a['order'] ?? 1) - (b['order'] ?? -1) 190 | }) 191 | 192 | // Replace each animatedElement object with its corresponding element 193 | for (let i = 0; i < animatedElementsList.length; i++) { 194 | animatedElementsList[i] = animatedElementsList[i].el 195 | } 196 | } 197 | 198 | // Function to animate the elements 199 | animateElements(parent, animatedElementsList, index) { 200 | // Initialise animation property arrays 201 | let animationFromPropertiesArray = [] 202 | let animationToPropertiesArray = [] 203 | 204 | // Create a matchMedia instance 205 | const matchMedia = this._gsap.matchMedia() 206 | 207 | // Get the screen media query 208 | const screen = parent.dataset.inviewScreen || this.getOption('screen') 209 | 210 | // Initialise a new gsap timeline 211 | matchMedia.add(screen, () => { 212 | let timeline = this._gsap.timeline({ 213 | scrollTrigger: { 214 | trigger: parent, 215 | start: parent.dataset.inviewStart || this.getOption('start'), 216 | invalidateOnRefresh: true, 217 | onEnter: async () => { 218 | timeline.play() 219 | timeline.hasPlayed = true 220 | 221 | parent.classList.add(this.getOption('viewedClass')) 222 | 223 | // Check if the parent has the 'data-inview-call' attribute and, if so, dispatch a custom event with the attribute's value as the event name 224 | if (parent.hasAttribute('data-inview-call')) { 225 | const customEventName = parent.getAttribute('data-inview-call') 226 | window.dispatchEvent( 227 | new CustomEvent(customEventName, { 228 | detail: { 229 | target: parent, 230 | }, 231 | }), 232 | ) 233 | } 234 | 235 | if (this.eventListeners['onEnter']) { 236 | this.emit('onEnter', parent) 237 | } 238 | }, 239 | onLeave: () => { 240 | if (parent.hasAttribute('data-inview-repeat')) { 241 | timeline.restart().pause() 242 | } 243 | if (this.eventListeners['onLeave']) { 244 | this.emit('onLeave', parent) 245 | } 246 | }, 247 | onEnterBack: async () => { 248 | if (parent.hasAttribute('data-inview-repeat')) { 249 | timeline.restart() 250 | timeline.hasPlayed = true 251 | } else if (!timeline.hasPlayed) { 252 | timeline.play() 253 | timeline.hasPlayed = true 254 | } 255 | if (this.eventListeners['onEnterBack']) { 256 | this.emit('onEnterBack', parent) 257 | } 258 | }, 259 | onLeaveBack: () => { 260 | if (parent.hasAttribute('data-inview-repeat')) { 261 | timeline.restart().pause() 262 | } 263 | if (this.eventListeners['onLeaveBack']) { 264 | this.emit('onLeaveBack', parent) 265 | } 266 | }, 267 | markers: this.getOption('debug') || parent.hasAttribute('data-inview-debug') ? true : false, // Modified line to include global debug option 268 | toggleClass: { 269 | targets: parent, 270 | className: this.getOption('inviewClass'), 271 | }, 272 | }, 273 | }) 274 | 275 | timeline.hasPlayed = false 276 | 277 | // Store the ScrollTrigger instance 278 | this.triggers.push(timeline.scrollTrigger) 279 | 280 | // Initialise a variable to hold the current time position on the timeline 281 | let currentTime = 0 282 | 283 | animatedElementsList.forEach((element) => { 284 | try { 285 | let animationFromProperties = this.getOption('animationFrom') 286 | let animationToProperties = this.getOption('animationTo') 287 | 288 | // Check if the element has custom animation properties defined in 'data-inview-from' and 'data-inview-to' 289 | if (element.dataset.inviewFrom) { 290 | animationFromProperties = JSON.parse(element.dataset.inviewFrom) 291 | } else if (parent.dataset.inviewFrom) { 292 | animationFromProperties = JSON.parse(parent.dataset.inviewFrom) 293 | } 294 | 295 | if (element.dataset.inviewTo) { 296 | animationToProperties = JSON.parse(element.dataset.inviewTo) 297 | } else if (parent.dataset.inviewTo) { 298 | animationToProperties = JSON.parse(parent.dataset.inviewTo) 299 | } 300 | 301 | // Push the properties for this element to the arrays 302 | animationFromPropertiesArray.push(animationFromProperties) 303 | animationToPropertiesArray.push(animationToProperties) 304 | 305 | // Set initial animation properties for the animated elements 306 | this._gsap.set(element, animationFromProperties) 307 | 308 | // Get the stagger time 309 | let staggerTime = parent.dataset.inviewStagger || this.getOption('stagger') 310 | 311 | // Add the animation to the timeline 312 | timeline.to( 313 | element, 314 | { 315 | ...animationToProperties, 316 | duration: parent.dataset.inviewDuration || this.getOption('duration'), 317 | delay: parent.dataset.inviewDelay || this.getOption('delay'), 318 | ease: parent.dataset.inviewEase || this.getOption('ease'), 319 | }, 320 | currentTime, 321 | ) 322 | 323 | // Increase the current time position by the stagger time for the next animation 324 | currentTime += parseFloat(staggerTime) 325 | } catch (e) { 326 | console.error(`An error occurred while animating the element: ${e}`) 327 | } 328 | }) 329 | 330 | // Pause the timeline initially, the onEnter/onEnterBack events will play/restart it 331 | timeline.pause() 332 | }) 333 | 334 | // Debug mode 335 | if (this.getOption('debug') || parent.hasAttribute('data-inview-debug')) { 336 | this.debugMode( 337 | parent, 338 | animatedElementsList, 339 | animationFromPropertiesArray, 340 | animationToPropertiesArray, 341 | index, 342 | ) 343 | } 344 | } 345 | 346 | // Function for debug mode logging 347 | debugMode(parent, animatedElementsList, animationFromProperties, animationToProperties, index) { 348 | console.group(`InviewDetection() debug instance (#${index + 1})`) 349 | console.log({ 350 | parent: parent, 351 | elements: animatedElementsList, 352 | screen: this.getOption('screen'), 353 | animationFrom: animationFromProperties, 354 | animationTo: animationToProperties, 355 | duration: this.getOption('duration'), 356 | delay: this.getOption('delay'), 357 | start: this.getOption('start'), 358 | ease: this.getOption('ease'), 359 | stagger: this.getOption('stagger'), 360 | }) 361 | console.groupEnd() 362 | } 363 | 364 | // Function to refresh ScrollTrigger instances 365 | refresh() { 366 | this._ScrollTrigger.refresh() 367 | 368 | if (this.eventListeners['refresh']) { 369 | this.emit('refresh', parent) 370 | } 371 | } 372 | 373 | // Function to stop the animations and ScrollTrigger instances 374 | stop() { 375 | // Kill ScrollTrigger instances created in this script 376 | this.triggers.forEach((st) => st.kill()) 377 | this.triggers = [] // Clear the triggers array 378 | 379 | // Kill all animations 380 | const allElements = this._gsap.utils.toArray(this.getOption('elements')).concat(this.animatedElementsList) 381 | allElements.forEach((element) => { 382 | this._gsap.killTweensOf(element) 383 | }) 384 | 385 | if (this.eventListeners['stop']) { 386 | this.emit('stop', parent) 387 | } 388 | } 389 | 390 | // Function to restart the animations and reinitialise everything 391 | restart() { 392 | // Kill all GSAP animations of the elements 393 | this._gsap.utils.toArray(this.getOption('elements')).forEach((element) => { 394 | this._gsap.killTweensOf(element) 395 | }) 396 | 397 | // Reinitialise everything 398 | this.init() 399 | 400 | if (this.eventListeners['restart']) { 401 | this.emit('restart', parent) 402 | } 403 | } 404 | } 405 | 406 | window.InviewDetection = InviewDetection 407 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | root: './docs' 3 | } 4 | --------------------------------------------------------------------------------