├── .babelrc.js ├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc ├── .release-it.json ├── .storybook ├── addons.js ├── config.js ├── preview-head.html └── webpack.config.js ├── LICENSE ├── README.md ├── package.json ├── src ├── ScrollObserver.ts ├── ScrollScene.ts ├── helpers │ ├── createArray.ts │ ├── errorLog.ts │ ├── index.ts │ ├── isFunc.ts │ ├── isObject.ts │ ├── isString.ts │ ├── roundOff.ts │ ├── scrollAnimationInit.ts │ └── stringContains.ts ├── index.ts ├── plugins │ └── debug.addIndicators.ts ├── scrollmagic-add-indicators.ts ├── scrollmagic-with-ssr.ts └── stories │ ├── Stylesheet.js │ └── story.js ├── tsconfig.json ├── tsconfig.types.cjs.json ├── tsconfig.types.esm.json ├── tslint.json └── yarn.lock /.babelrc.js: -------------------------------------------------------------------------------- 1 | const tsconfig = require('./tsconfig.json') 2 | const tsPaths = tsconfig.compilerOptions.paths 3 | 4 | /* 5 | Convert tsconfig path 6 | { 7 | '@components/*': [ './src/components/*' ], 8 | } 9 | 10 | To babelrc aliases 11 | 12 | { 13 | @components': './src/components', 14 | } 15 | */ 16 | 17 | const webpackTsPaths = {} 18 | 19 | Object.keys(tsPaths).forEach(function(key) { 20 | const newKey = key.replace('/*', '') 21 | 22 | // this only support single src aliases, not arrays of aliases 23 | // might need to upgrade later if it's really that necessary 24 | const value = tsPaths[key][0].replace('/*', '') 25 | 26 | webpackTsPaths[newKey] = value 27 | }) 28 | 29 | module.exports = { 30 | presets: ['@babel/preset-env', '@babel/preset-react', '@babel/typescript'], 31 | plugins: [ 32 | '@babel/plugin-proposal-class-properties', 33 | '@babel/proposal-object-rest-spread', 34 | '@babel/plugin-proposal-export-default-from', 35 | '@babel/plugin-transform-runtime', 36 | 'babel-plugin-add-react-displayname', 37 | [ 38 | 'module-resolver', 39 | { 40 | root: ['.'], 41 | alias: webpackTsPaths, 42 | }, 43 | ], 44 | ], 45 | comments: false, 46 | env: { 47 | commonjs: { 48 | presets: [ 49 | [ 50 | '@babel/preset-env', 51 | { 52 | useBuiltIns: false, 53 | }, 54 | ], 55 | ], 56 | }, 57 | esm: { 58 | presets: [['@babel/preset-env', { modules: false }]], 59 | plugins: ['dynamic-import-node', ['@babel/plugin-transform-runtime', { useESModules: true }]], 60 | }, 61 | }, 62 | } 63 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build 2 | dist 3 | node_modules 4 | .cache 5 | ./src/lib 6 | /src/**/stories/* 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es6: true 6 | }, 7 | parser: 'babel-eslint', 8 | parserOptions: { 9 | ecmaVersion: 6, 10 | sourceType: 'module', 11 | ecmaFeatures: { 12 | jsx: true, 13 | modules: true, 14 | experimentalObjectRestSpread: true 15 | } 16 | }, 17 | extends: ['eslint:recommended', 'plugin:react/recommended'], 18 | plugins: ['react'], 19 | rules: { 20 | indent: ['error', 2], 21 | 'linebreak-style': ['error', 'unix'], 22 | quotes: ['error', 'single'], 23 | semi: ['error', 'never'] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _wip 2 | .cache 3 | .npmrc 4 | .out 5 | build 6 | dist 7 | node_modules 8 | package 9 | package-lock.json 10 | todo 11 | yarn-error.log 12 | public -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jonkwheeler/ScrollScene/9861e17c66c0f6a29ecd7777b6bdff2f1cadb6d0/.npmignore -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "semi": false, 6 | "jsxBracketSameLine": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "arrowParens": "avoid", 10 | "printWidth": 120 11 | } 12 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": false, 3 | "requireCleanWorkingDir": true, 4 | "buildCommand": "yarn nuke:build:rebuild" 5 | } 6 | -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | import '@storybook/addon-knobs/register' 2 | import '@storybook/addon-notes/register' 3 | import '@storybook/addon-storysource/register' 4 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure, addDecorator } from '@storybook/react' 2 | import { withKnobs } from '@storybook/addon-knobs' 3 | 4 | addDecorator( 5 | withKnobs({ 6 | escapeHTML: false, 7 | }), 8 | ) 9 | 10 | function loadStories() { 11 | const req = require.context('../src', true, /story.(js|jsx|ts|tsx)$|stories\/*.(js|jsx|ts|tsx)/) 12 | req.keys().forEach(filename => req(filename)) 13 | } 14 | 15 | configure(loadStories, module) 16 | -------------------------------------------------------------------------------- /.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 2 | 40 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin') 3 | const tsconfig = require('../tsconfig.json') 4 | const tsPaths = tsconfig.compilerOptions.paths 5 | 6 | /* 7 | Convert tsconfig path 8 | { 9 | '@storybook-helpers/*': [ '../.storybook/helpers/*' ] 10 | } 11 | 12 | To webpack aliases 13 | 14 | { 15 | '@components': path.resolve(__dirname, '../src/components') 16 | } 17 | */ 18 | 19 | const webpackTsPaths = {} 20 | 21 | Object.keys(tsPaths).forEach(function(key) { 22 | const newKey = key.replace('/*', '') 23 | const regex = /^.{0,2}/ 24 | 25 | // this only support single src aliases, not arrays of aliases 26 | // might need to upgrade later if it's really that necessary 27 | const value = tsPaths[key][0] 28 | const returnedValue = (regex.test('./') ? value.replace(regex, '../') : value).replace('/*', '') 29 | 30 | webpackTsPaths[newKey] = path.resolve(__dirname, returnedValue) 31 | }) 32 | 33 | const extensions = ['.ts', '.tsx', '.js', '.jsx', '.json'] 34 | 35 | module.exports = ({ config, mode }) => { 36 | // config 37 | config.module.rules.push({ 38 | test: /\.(ts|tsx)$/, 39 | use: { 40 | loader: require.resolve('ts-loader'), 41 | options: { 42 | // disable checker as it's handled by the checker plugin in parallel 43 | transpileOnly: true, 44 | }, 45 | }, 46 | }) 47 | 48 | // runs tslint and typechecks 49 | config.plugins.push(new ForkTsCheckerWebpackPlugin()) 50 | 51 | // Uncomment this later. Causing build issues now. 52 | // config.plugins.push(new TSDocgenPlugin()) 53 | 54 | // make storybook recognize ts and tsx files 55 | config.resolve.extensions.push(...extensions) 56 | 57 | // do not use TsconfigPathsPlugin, it will not resolve correctly when webpack has a different root from tsconfig, and baseUrl is bs 58 | // this shiz works 🎉 59 | config.resolve.alias = webpackTsPaths 60 | 61 | config.resolve.modules = ['node_modules', path.resolve(__dirname, '../src')] 62 | 63 | return config 64 | } 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jon Wheeler 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 | [![GitHub tag (latest by date)](https://img.shields.io/github/v/tag/jonkwheeler/ScrollScene?label=latest%20release)](https://img.shields.io/github/v/tag/jonkwheeler/ScrollScene?color=forest&label=latest%20release) 2 | 3 | # ScrollScene 4 | 5 | ScrollScene is an extra layer on top of ScrollMagic as well as using IntersectionObserver to achieve similar effects. 6 | 7 | ## Examples 8 | 9 | View the online [Storybook](https://scrollscene.jonkwheeler.now.sh). 10 | 11 | ## Install 12 | 13 | ```js 14 | yarn add scrollscene scrollmagic 15 | ``` 16 | 17 | or 18 | 19 | ```js 20 | npm install scrollscene scrollmagic 21 | ``` 22 | 23 | If plan to use only the ScrollObserver, currently you can 24 | 25 | ```js 26 | yarn add scrollscene && yarn add -D scrollmagic 27 | ``` 28 | 29 | to avoid import errors. 30 | 31 | ## Import 32 | 33 | ### ScrollScene (uses ScrollMagic) 34 | 35 | ```js 36 | import { ScrollScene } from 'scrollscene' 37 | ``` 38 | 39 | ### ScrollObserver (uses IntersectionObserver) 40 | 41 | ```js 42 | import { ScrollObserver } from 'scrollscene' 43 | ``` 44 | 45 | ### ScrollMagic (with SSR catch) 46 | 47 | ```js 48 | import { ScrollMagic } from 'scrollscene' 49 | ``` 50 | 51 | or 52 | 53 | ```js 54 | import { ScrollMagicSsr } from 'scrollscene' 55 | ``` 56 | 57 | `ScrollMagic` and `ScrollMagicSsr` are the exact same thing. 58 | 59 | ## Options 60 | 61 | ### ScrollScene Options (uses ScrollMagic) 62 | 63 | | option | Description / Example | 64 | | ---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 65 | | `breakpoints` | `breakpoints: { 0: false, 768: true }` is used to set responsiveness of the new ScrollMagic.Scene, mobile-first. | 66 | | `controller` | `controller: { vertical: false }`. Add anything from [new ScrollMagic.Controller(options)](http://scrollmagic.io/docs/ScrollMagic.Controller.html#constructor). | 67 | | `duration` | `duration: '100%'` OR `duration: { 0: '50%', 768: '100% }` is used to set responsiveness of the new ScrollMagic.Scene, mobile-first. OR set as a dom node (HTMLElement) `duration: triggerElement` and the scene will last as long as the height of the element. | 68 | | `gsap` | Init a Gsap timeline with `gsap: { timeline: myTimeline, reverseSpeed: 2, yoyo: true, delay: 2 }`. | 69 | | `offset` | Used to change the ScrollMagic `offset`. | 70 | | `scene` | `scene: { loglevel: 1 }`. Add anything from [new ScrollMagic.Scene(options)](http://scrollmagic.io/docs/ScrollMagic.Scene.html#constructor). | 71 | | `toggle` | Toggle a className on an element with `toggle: { element: containerRef.current, className: 'lets-do-this' }`. The `element` key does _not_ accept string; eg: `.className`. Use a dom node selector instead. | 72 | | `triggerElement` | `triggerElement: document.querySelector('#element')` is used to set the element you wish to trigger events based upon. Does _not_ accept string; eg: `.className`. Use a dom node selector instead. Optional: If left blank, will use top of page. | 73 | | `triggerHook` | Used to change the ScrollMagic `triggerHook`. | 74 | | methods | You can actually apply all the ScrollMagic.Scene methods to `ScrollScene`, like `const scrollScene = new ScrollScene({...}); scrollScene.Scene.on('enter', ())` or `setPin`. See all the options here: http://scrollmagic.io/docs/ScrollMagic.Scene.html. The same goes for ScrollMagic.Controller, `scrollScene.Controller.destroy(true)`, but be careful if you're using the built-in `globalController`. | 75 | 76 | ### ScrollObserver Options (uses IntersectionObserver) 77 | 78 | | option | Description / Example | 79 | | -------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 80 | | `breakpoints` | `breakpoints: { 0: false, 768: true }` is used to set responsiveness of the new ScrollMagic.Scene, mobile-first. | | 81 | | `gsap` | Init a Gsap timeline with `gsap: { timeline: myTimeline, reverseSpeed: 2, yoyo: true, delay: 2 }`. | 82 | | `observer` | `observer: { rootMargin: '-50% 0%' }` is used to pass extra options to pass the IntersectionObserver, like `root`, `rootMargin`, or `threshold` (to override the thresholds option). `observer: { rootMargin: '0px', threshold: 1.0 }` | 83 | | `offset` | Used to change the `rootMargin` easy. `offset: '-10%` will be `rootMargin: '-10% 0%'`. This is a bit wonky and needs more testing. | 84 | | `thresholds` | `thresholds: 1` is to set the number of thresholds you want. `thresholds: 100 = [0, 0.1, 0.2, ... 0.98, 0.99, 1]`. It's easy to use `whenVisible`. | 85 | | `toggle` | Toggle a className on an element with `toggle: { element: containerRef.current, className: 'lets-do-this' }`. The `element` key does _not_ accept string; eg: `.className`. Use a dom node selector instead. | 86 | | `triggerElement` | `triggerElement: document.querySelector('#element')` is used to set the element you wish to trigger events based upon. Does _not_ accept string; eg: `.className`. Use a dom node selector instead. | 87 | | `useDuration` | `useDuration: true` to use the percentage of element visibility to scrub the gsap timeline. Similar to ScrollMagic Duration on a Gsap timeline, but not quite the same if the element is longer than the viewport height, thus the element visibility will never reach 100%, thus the gsap timeline will never reach 100%. | 88 | | `destroyImmediately` | `destroyImmediately: true` to destroy the scene immediately after firing once the element is visible. | 89 | | `whenVisible` | `whenVisible: '50%'` make the scene active when the triggerElement is visible whatever percentage you set. "50%" means to fire the event when the element is 50% in the viewport. This will override `thresholds`. | 90 | | `callback` | For adding callback functions. Make sure you pass functions. You can supply one or both callbacks. `callback: { active: () => (), notActive: () => () }` | 91 | 92 | See below for examples. 93 | 94 | ## Key Notes 95 | 96 | - This project sought out to overcome the issues of using Gsap3 with ScrollMagic, as well as ESM related problems. In the end it added more features, like video playback, scene init breakpoints, scene duration breakpoints, gsap speed controls, and using an IntersectionObserver when it fits your usecase better. 97 | - Is written in TypeScript so you have access to the types. 98 | - This does not include `gsap` or `scrollmagic`. If you plan to use them, you'll need to install them in addition to `scrollscene`. If you don't plan to use ScrollScene, you currently still have to install `scrollmagic`, but can so as a dev dependency. `yarn add -D scrollmagic`, 99 | - This works with Gsap without having to import the extra `animation.gsap.js` file from ScrollMagic (though you'll have to install in yourself `yarn add gsap` or `npm install gsap`). In turn this is smaller than using ScrollMagic and animation.gsap.js. 100 | - It allows for scene init breakpoints, and for scene duration breakpoints. This will also will on SSR if implemented correctly. 101 | - You do not need to create a ScrollMagic controller. It is done for you. 102 | - This will Tree Shake if your webpack is set up correctly. Next.js, for example, does this for you. Thus you can just `ScrollObserver` and not `ScrollScene` if you wanted and your build should exclude `ScrollScene` and `scrollmagic` (as long as you did import them). 103 | - Does not work with `jQuery`. You need to provide a domNodeSelector. Whether a `document.querySelector('#element')` or React ref `myRef.current`. 104 | - You can add all the methods from ScrollMagic.Scene directly onto the `scrollScene`. See options here http://scrollmagic.io/docs/ScrollMagic.Scene.html. You can do a `setPin`, or `on` event handler. 105 | 106 | ## Next Steps 107 | 108 | - Add native setPin functionality using ScrollObserver to deprecate using it with ScrollMagic. 109 | - Add out-of-the-box split text animations, svg line draw, etc... 110 | - Move ScrollObserver to a separate package. 111 | 112 | ## Usage 113 | 114 | ### ScrollScene (uses ScrollMagic) 115 | 116 | ```js 117 | import { ScrollScene } from 'scrollscene' 118 | 119 | const myElement = document.querySelector('#element') 120 | 121 | const scrollScene = new ScrollScene({ 122 | triggerElement: myElement, 123 | }) 124 | ``` 125 | 126 | #### Toggle a className 127 | 128 | ```js 129 | import { ScrollScene } from 'scrollscene' 130 | 131 | const domNode = document.querySelector('#element') 132 | const anotherDomNode = document.querySelector('#element2') 133 | 134 | const scrollScene = new ScrollScene({ 135 | triggerElement: domNode, 136 | toggle: { 137 | element: anotherDomNode, 138 | className: 'turn-blue', 139 | }, 140 | }) 141 | ``` 142 | 143 | #### Toggle a className on a duration 144 | 145 | ```js 146 | import { ScrollScene } from 'scrollscene' 147 | 148 | const domNode = document.querySelector('#element') 149 | const anotherDomNode = document.querySelector('#element2') 150 | 151 | const scrollScene = new ScrollScene({ 152 | triggerElement: domNode, 153 | toggle: { 154 | element: anotherDomNode, 155 | className: 'turn-blue', 156 | reverse: true, 157 | }, 158 | triggerHook: 1, 159 | duration: '100%', 160 | }) 161 | ``` 162 | 163 | #### Add extra options from ScrollMagic (like a triggerHook or offset) 164 | 165 | ```js 166 | import { ScrollScene } from 'scrollscene' 167 | 168 | const domNode = document.querySelector('#element') 169 | const anotherDomNode = document.querySelector('#element2') 170 | 171 | const scrollScene = new ScrollScene({ 172 | triggerElement: domNode, 173 | toggle: { 174 | element: anotherDomNode, 175 | className: 'turn-blue', 176 | }, 177 | offset: 50, 178 | triggerHook: 0.5, 179 | }) 180 | ``` 181 | 182 | or anything from [new ScrollMagic.Scene(options)](http://scrollmagic.io/docs/ScrollMagic.Scene.html#constructor) under the `scene` key to contain those options. 183 | 184 | ```js 185 | import { ScrollScene } from 'scrollscene' 186 | 187 | const domNode = document.querySelector('#element') 188 | const anotherDomNode = document.querySelector('#element2') 189 | 190 | const scrollScene = new ScrollScene({ 191 | triggerElement: domNode, 192 | toggle: { 193 | element: anotherDomNode, 194 | className: 'turn-blue', 195 | }, 196 | scene: { 197 | logLevel: 1, 198 | }, 199 | }) 200 | ``` 201 | 202 | Same for [new ScrollMagic.Controller(options)](http://scrollmagic.io/docs/ScrollMagic.Controller.html#constructor) under the `controller` key to contain those options. 203 | 204 | ```js 205 | import { ScrollScene } from 'scrollscene' 206 | 207 | const domNode = document.querySelector('#element') 208 | const anotherDomNode = document.querySelector('#element2') 209 | 210 | const scrollScene = new ScrollScene({ 211 | triggerElement: domNode, 212 | toggle: { 213 | element: anotherDomNode, 214 | className: 'turn-blue', 215 | }, 216 | controller: { 217 | logLevel: 3, 218 | }, 219 | }) 220 | ``` 221 | 222 | Use a new local controller 223 | 224 | ```js 225 | import { ScrollScene } from 'scrollscene' 226 | 227 | const domNode = document.querySelector('#element') 228 | const anotherDomNode = document.querySelector('#element2') 229 | 230 | const scrollScene = new ScrollScene({ 231 | triggerElement: domNode, 232 | toggle: { 233 | element: anotherDomNode, 234 | className: 'turn-blue', 235 | }, 236 | useGlobalController: false, 237 | }) 238 | ``` 239 | 240 | Add event handlers (`on`) or `setPin`. See options here http://scrollmagic.io/docs/ScrollMagic.Scene.html. 241 | 242 | ```js 243 | import { ScrollScene } from 'scrollscene' 244 | 245 | const domNode = document.querySelector('#element') 246 | 247 | const scrollScene = new ScrollScene({ 248 | triggerElement: domNode, 249 | }) 250 | 251 | scrollScene.Scene.on('enter', function(event) { 252 | console.log('Scene entered.') 253 | }) 254 | ``` 255 | 256 | Add methods to the controller. See options here http://scrollmagic.io/docs/ScrollMagic.Controller.html. But be careful if you're using the built-in `globalController`, as it'll impact all the scenes you have. 257 | 258 | ```js 259 | import { ScrollScene } from 'scrollscene' 260 | 261 | const domNode = document.querySelector('#element') 262 | 263 | const scrollScene = new ScrollScene({ 264 | triggerElement: domNode, 265 | }) 266 | 267 | scrollScene.Controller.destroy(true) 268 | ``` 269 | 270 | #### Using GSAP (Greensock) 271 | 272 | ```js 273 | import { ScrollScene } from 'scrollscene' 274 | import { gsap } from 'gsap' 275 | 276 | // create a timeline and add a tween 277 | const myTimeline = gsap.timeline({ paused: true }) 278 | const domNode = document.querySelector('#element') 279 | const scrollTrigger = document.querySelector('.scroll-trigger-01') 280 | 281 | myTimeline.to(domNode, { 282 | x: -200, 283 | duration: 1, 284 | ease: 'power2.out', 285 | }) 286 | 287 | const scrollScene = new ScrollScene({ 288 | triggerElement: scrollTrigger, 289 | gsap: { 290 | timeline: myTimeline, 291 | }, 292 | }) 293 | ``` 294 | 295 | #### Using GSAP (Greensock), and setting the reserveSpeed 296 | 297 | ```js 298 | import { ScrollScene } from 'scrollscene' 299 | import { gsap } from 'gsap' 300 | 301 | // create a timeline and add a tween 302 | const myTimeline = gsap.timeline({ paused: true }) 303 | const domNode = document.querySelector('#element') 304 | const scrollTrigger = document.querySelector('.scroll-trigger-01') 305 | 306 | myTimeline.to(domNode, { 307 | x: -200, 308 | duration: 1, 309 | ease: 'power2.out', 310 | }) 311 | 312 | const scrollScene = new ScrollScene({ 313 | triggerElement: scrollTrigger, 314 | gsap: { 315 | timeline: tl, 316 | reverseSpeed: 4, 317 | }, 318 | }) 319 | ``` 320 | 321 | #### Using GSAP (Greensock), and tying it to the user scrolling with a duration 322 | 323 | ```js 324 | import { ScrollScene } from 'scrollscene' 325 | import { gsap } from 'gsap' 326 | 327 | // create a timeline and add a tween 328 | const myTimeline = gsap.timeline({ paused: true }) 329 | const domNode = document.querySelector('#element') 330 | const scrollTrigger = document.querySelector('.scroll-trigger-01') 331 | 332 | myTimeline.to(domNode, { 333 | x: -200, 334 | duration: 1, 335 | ease: 'power2.out', 336 | }) 337 | 338 | const scrollScene = new ScrollScene({ 339 | triggerElement: scrollTrigger, 340 | gsap: { 341 | timeline: tl, 342 | }, 343 | duration: 500, 344 | }) 345 | ``` 346 | 347 | #### Add Indicators (Using the addIndicators plugin from ScrollMagic) 348 | 349 | I added to this package a modified version of the addIndicators plugin. It's easy to use. Just remember to remove it after you're done testing so it doesn't go into production. 350 | 351 | ```js 352 | import { ScrollScene, addIndicators } from 'scrollscene' 353 | 354 | const domNode = document.querySelector('#element') 355 | const anotherDomNode = document.querySelector('#element2') 356 | 357 | const scrollScene = new ScrollScene({ 358 | triggerElement: domNode, 359 | toggle: { 360 | element: anotherDomNode, 361 | className: 'turn-blue', 362 | reverse: true, 363 | }, 364 | triggerHook: 1, 365 | duration: '100%', 366 | }) 367 | 368 | scrollScene.Scene.addIndicators({ name: 'pin scene', colorEnd: '#FFFFFF' }) 369 | ``` 370 | 371 | _Note_: Notice that it's `scrollScene.Scene`. `scrollScene` actually returns `Scene` and `Controller` methods, which you can then modify. `scrollScene.addIndicators` will not work. 372 | 373 | Alternatively you could do this and it'll apply to the built-in globalController... 374 | 375 | ```js 376 | import { ScrollScene, addIndicators } from 'scrollscene' 377 | 378 | const scrollScene = new ScrollScene({ 379 | controller: { 380 | addIndicators: true, 381 | }, 382 | }) 383 | ``` 384 | 385 | or 386 | 387 | ```js 388 | import { ScrollScene, addIndicators } from 'scrollscene' 389 | 390 | const scrollScene = new ScrollScene({ 391 | ...options, 392 | }) 393 | 394 | scrollScene.Controller({ addIndicators: true }) 395 | ``` 396 | 397 | ### ScrollObserver (uses IntersectionObserver) 398 | 399 | #### Toggle a className while element is visible on the page 400 | 401 | ```js 402 | import { ScrollObserver } from 'scrollscene' 403 | import { gsap } from 'gsap' 404 | 405 | const domNode = document.querySelector('#element') 406 | const anotherDomNode = document.querySelector('#element2') 407 | 408 | const scrollObserver = new ScrollObserver({ 409 | triggerElement: domNode, 410 | toggle: { 411 | element: anotherDomNode, 412 | className: 'turn-blue', 413 | }, 414 | }) 415 | ``` 416 | 417 | #### Toggle a Gsap animation while element is visible on the page 418 | 419 | ```js 420 | import { ScrollObserver } from 'scrollscene' 421 | import { gsap } from 'gsap' 422 | 423 | // create a timeline and add a tween 424 | const tl = gsap.timeline({ paused: true }) 425 | const domNode = document.querySelector('#element') 426 | const squareElement = document.querySelector('#square') 427 | 428 | tl.to(squareElement, { 429 | x: -200, 430 | duration: 1, 431 | ease: 'power2.out', 432 | }) 433 | 434 | const scrollObserver = new ScrollObserver({ 435 | triggerElement: domNode, 436 | gsap: { 437 | timeline: tl, 438 | }, 439 | }) 440 | ``` 441 | 442 | #### Toggle a Gsap animation while element is visible on the page with a yoyo effect and repeat delay of 0 443 | 444 | ```js 445 | import { ScrollObserver } from 'scrollscene' 446 | import { gsap } from 'gsap' 447 | 448 | // create a timeline and add a tween 449 | const tl = gsap.timeline({ paused: true }) 450 | const domNode = document.querySelector('#element') 451 | const squareElement = document.querySelector('#square') 452 | 453 | tl.to(squareElement, { 454 | x: -200, 455 | duration: 1, 456 | ease: 'power2.out', 457 | }) 458 | 459 | const scrollObserver = new ScrollObserver({ 460 | triggerElement: domNode, 461 | gsap: { 462 | timeline: tl, 463 | yoyo: true, 464 | delay: 0, 465 | }, 466 | }) 467 | ``` 468 | 469 | #### Scrub a Gsap timeline based on element visibility 470 | 471 | ```js 472 | import { ScrollObserver } from 'scrollscene' 473 | import { gsap } from 'gsap' 474 | 475 | // create a timeline and add a tween 476 | const tl = gsap.timeline({ paused: true }) 477 | const domNode = document.querySelector('#element') 478 | const squareElement = document.querySelector('#square') 479 | 480 | tl.to(squareElement, { 481 | x: -200, 482 | duration: 1, 483 | ease: 'power2.out', 484 | }) 485 | 486 | const scrollObserver = new ScrollObserver({ 487 | triggerElement: domNode, 488 | gsap: { 489 | timeline: tl, 490 | }, 491 | useDuration: true, 492 | }) 493 | ``` 494 | 495 | #### Start a video when an element is visible and pause the video when it's not 496 | 497 | ```js 498 | import { ScrollObserver } from 'scrollscene' 499 | 500 | const domNode = document.querySelector('#element') 501 | const videoTagDomNode = document.querySelector('#video') 502 | 503 | const scrollObserver = new ScrollObserver({ 504 | triggerElement: domNode, 505 | video: { 506 | element: videoTagDomNode, 507 | playingClassName: 'is-playing', 508 | pausedClassName: 'is-paused', 509 | }, 510 | }) 511 | ``` 512 | 513 | #### Using a scene once 514 | 515 | ```js 516 | import { ScrollObserver } from 'scrollscene' 517 | 518 | const domNode = document.querySelector('#element') 519 | 520 | const scrollObserver = new ScrollObserver({ 521 | triggerElement: domNode, 522 | destroyImmediately: true, 523 | }) 524 | ``` 525 | 526 | #### Set a percentage for the visibility threshold 527 | 528 | ```js 529 | import { ScrollObserver } from 'scrollscene' 530 | 531 | const domNode = document.querySelector('#element') 532 | 533 | const scrollObserver = new ScrollObserver({ 534 | triggerElement: domNode, 535 | whenVisible: '50%', 536 | }) 537 | ``` 538 | 539 | #### Set a different threshold 540 | 541 | The below would create an array of 100 thresholds ([0, 0.1, 0.2, ... 0.98, 0.99, 1]), effectively says any percent from 1 to 100 of the element intersecting the viewport should trigger the scene. 542 | 543 | ```js 544 | import { ScrollObserver } from 'scrollscene' 545 | 546 | const domNode = document.querySelector('#element') 547 | 548 | const scrollObserver = new ScrollObserver({ 549 | triggerElement: domNode, 550 | thresholds: 100, 551 | }) 552 | ``` 553 | 554 | #### Extra observer options 555 | 556 | The below adds extra options to the IntersectionObserver. See others properities you could add [here](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver). 557 | 558 | ```js 559 | import { ScrollObserver } from 'scrollscene' 560 | 561 | const domNode = document.querySelector('#element') 562 | 563 | const scrollObserver = new ScrollObserver({ 564 | triggerElement: domNode, 565 | observer: { rootMargin: '-50% 0%' }, 566 | }) 567 | ``` 568 | 569 | or 570 | 571 | ```js 572 | import { ScrollObserver } from 'scrollscene' 573 | 574 | const domNode = document.querySelector('#element') 575 | 576 | const scrollObserver = new ScrollObserver({ 577 | triggerElement: domNode, 578 | observer: { 579 | rootMargin: '0px', 580 | threshold: 1.0, 581 | }, 582 | }) 583 | ``` 584 | 585 | ### Destroy the scene 586 | 587 | Whatever you've named your scene, whether `const scrollScene` or `const scrollObserver`, you can destroy it with... 588 | 589 | ```js 590 | scrollScene.destroy() 591 | ``` 592 | 593 | ```js 594 | scrollObserver.destroy() 595 | ``` 596 | 597 | ### Using React? 598 | 599 | With React it's best to do this inside either a `useEffect` hook or using the `componentDidMount` and `componentWillUnmount` lifecycle. Whatever you choose, make sure to destroy the scene on the unmount. 600 | 601 | See the Storybook source for good examples (story.js) [found here](https://github.com/jonkwheeler/ScrollScene/blob/master/src/stories/story.js). 602 | 603 | ```js 604 | import { ScrollScene } from 'scrollscene' 605 | 606 | const MyComponent = () => { 607 | // init ref 608 | const containerRef = React.useRef(null) 609 | const triggerRef = React.useRef(null) 610 | 611 | React.useEffect(() => { 612 | const { current: containerElement } = containerRef 613 | const { current: triggerElement } = triggerRef 614 | 615 | if (!containerElement && !triggerElement) { 616 | return undefined 617 | } 618 | 619 | const scrollScene = new ScrollScene({ 620 | triggerElement: triggerElement, 621 | toggle: { 622 | element: containerElement, 623 | className: 'turn-blue', 624 | }, 625 | }) 626 | 627 | // destroy on unmount 628 | return () => { 629 | scrollScene.destroy() 630 | } 631 | }) 632 | 633 | return ( 634 |
635 |
636 | 637 |

Basic Example

638 |

Scroll Down

639 | 640 |
641 | 642 |
When this hits the top the page will turn blue
643 | 644 |
645 |
646 | ) 647 | } 648 | ``` 649 | 650 | ### Other options 651 | 652 | You can now set `breakpoints` so you scene is more responsive. They work mobile first. The below would set up a scene on tablet, but not mobile, and resizing will init and destroy. 653 | 654 | ```js 655 | const scrollScene = new ScrollScene({ 656 | breakpoints: { 0: false, 768: true }, 657 | }) 658 | ``` 659 | 660 | `duration` also can be responsive, finally! The below would set up a scene that lasts 50vh on mobile, 100% after. 661 | 662 | ```js 663 | const scrollScene = new ScrollScene({ 664 | duration: { 0: '50%', 768: '100%' }, 665 | }) 666 | ``` 667 | 668 | ## Polyfill 669 | 670 | In order to use ScrollObserver on IE, you'll need a polyfill IntersectionObserver. You can do this with loading a polyfill script from https://polyfill.io/. 671 | 672 | ### Using Next.js or Webpack? 673 | 674 | With Next.js you can load polyfills another way. See https://github.com/zeit/next.js/blob/canary/examples/with-polyfills/client/polyfills.js. 675 | 676 | Add following to `client/polyfills.js` 677 | 678 | ```js 679 | /* 680 | * This files runs at the very beginning (even before React and Next.js core) 681 | * https://github.com/zeit/next.js/blob/canary/examples/with-polyfills/client/polyfills.js 682 | */ 683 | 684 | // https://www.npmjs.com/package/intersection-observer 685 | import 'intersection-observer' 686 | ``` 687 | 688 | And then modify the `next.config.js` 689 | 690 | ```js 691 | // next.config.js 692 | 693 | const nextConfig = { 694 | webpack(config) { 695 | /* 696 | * Add polyfills 697 | * https://github.com/zeit/next.js/blob/canary/examples/with-polyfills/next.config.js 698 | */ 699 | 700 | const originalEntry = config.entry 701 | config.entry = async () => { 702 | const entries = await originalEntry() 703 | 704 | if (entries['main.js'] && !entries['main.js'].includes('./client/polyfills.js')) { 705 | entries['main.js'].unshift('./client/polyfills.js') 706 | } 707 | 708 | return entries 709 | } 710 | 711 | return config 712 | }, 713 | } 714 | 715 | module.exports = nextConfig 716 | ``` 717 | 718 | For more on ScrollMagic, hit up [scrollmagic.io/](https://www.scrollmagic.io/) and [https://github.com/janpaepke/ScrollMagic](https://github.com/janpaepke/ScrollMagic) 719 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrollscene", 3 | "title": "ScrollScene", 4 | "version": "0.0.24", 5 | "description": "ScrollScene is an extra layer on top of ScrollMagic as well as using IntersectionObserver to achieve similar effects.", 6 | "author": "Jon K. Wheeler", 7 | "license": "MIT", 8 | "main": "./dist/commonjs/index.js", 9 | "module": "./dist/esm/index.js", 10 | "types": "./dist/commonjs/index.d.ts", 11 | "files": [ 12 | "dist" 13 | ], 14 | "sideEffects": false, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/jonkwheeler/ScrollScene.git" 18 | }, 19 | "publishConfig": { 20 | "scope": "jonkwheeler", 21 | "registry": "https://registry.npmjs.org/" 22 | }, 23 | "config": { 24 | "dirBuild": "./dist", 25 | "dirCommonjs": "./dist/commonjs", 26 | "dirEsm": "./dist/esm", 27 | "dirSrc": "./src", 28 | "dirStorybook": "./.storybook", 29 | "dirStorybookBuild": "./public", 30 | "portStorybook": "8888" 31 | }, 32 | "scripts": { 33 | "build:commonjs": "yarn nuke:commonjs && cross-env BABEL_ENV=commonjs babel $npm_package_config_dirSrc --out-dir $npm_package_config_dirCommonjs --extensions \".ts,.tsx,.js,.jsx\" --ignore \"**/*.stories.js\",\"**/story.js\",\"**/stories\",\"**/*.test.js\",\"test.js\",\"**/test\",\"**/*.md\",\"**/docs\" --source-maps inline", 34 | "build:esm": "yarn nuke:esm && cross-env BABEL_ENV=esm babel $npm_package_config_dirSrc --out-dir $npm_package_config_dirEsm --extensions \".ts,.tsx,.js,.jsx\" --ignore \"**/*.stories.js\",\"**/story.js\",\"**/stories\",\"**/*.test.js\",\"test.js\",\"**/test\",\"**/*.md\",\"**/docs\" --source-maps inline", 35 | "build:package": "yarn build:commonjs && yarn build:esm && yarn build:types", 36 | "build:storybook": "build-storybook -s $npm_package_config_dirStorybook -o $npm_package_config_dirStorybookBuild", 37 | "build:types:commonjs": "tsc --project tsconfig.types.cjs.json && tsconfig-replace-paths --project tsconfig.types.cjs.json", 38 | "build:types:esm": "tsc --project tsconfig.types.esm.json && tsconfig-replace-paths --project tsconfig.types.esm.json", 39 | "build:types": "yarn build:types:commonjs && yarn build:types:esm", 40 | "build": "yarn build:storybook", 41 | "dev": "yarn start:storybook", 42 | "lint": "eslint .", 43 | "nuke:all:install:dev": "yarn nuke:all && yarn && yarn dev", 44 | "nuke:all:install": "yarn nuke:all && yarn", 45 | "nuke:all": "rm -rf node_modules .cache $npm_package_config_dirBuild yarn.lock yarn-error.log package-lock.json", 46 | "nuke:build:rebuild": "yarn nuke:build && yarn build:package", 47 | "nuke:build": "rm -rf $npm_package_config_dirBuild", 48 | "nuke:commonjs": "rm -rf $npm_package_config_dirBuild", 49 | "nuke:esm": "rm -rf $npm_package_config_dirEsm", 50 | "prepublishOnly": "yarn nuke:build:rebuild", 51 | "release": "yarn release-it", 52 | "start:storybook": "start-storybook -p $npm_package_config_portStorybook -c $npm_package_config_dirStorybook", 53 | "storybook": "yarn start:storybook" 54 | }, 55 | "dependencies": { 56 | "@babel/runtime": "^7.8.4", 57 | "is-client": "^0.0.2", 58 | "lodash.throttle": "^4.1.1" 59 | }, 60 | "devDependencies": { 61 | "@babel/cli": "^7.7.7", 62 | "@babel/core": "^7.7.7", 63 | "@babel/plugin-proposal-class-properties": "^7.7.4", 64 | "@babel/plugin-proposal-export-default-from": "^7.7.4", 65 | "@babel/plugin-proposal-object-rest-spread": "^7.7.7", 66 | "@babel/plugin-transform-runtime": "^7.8.3", 67 | "@babel/preset-env": "^7.7.7", 68 | "@babel/preset-react": "^7.7.4", 69 | "@babel/preset-typescript": "^7.7.7", 70 | "@storybook/addon-knobs": "^5.2.8", 71 | "@storybook/addon-notes": "^5.2.8", 72 | "@storybook/addon-storysource": "^5.3.6", 73 | "@storybook/react": "^5.2.8", 74 | "babel-eslint": "^10.0.3", 75 | "babel-loader": "^8.0.6", 76 | "babel-plugin-add-react-displayname": "^0.0.5", 77 | "babel-plugin-module-resolver": "^4.0.0", 78 | "cross-env": "^6.0.3", 79 | "eslint": "^6.8.0", 80 | "eslint-plugin-react": "^7.17.0", 81 | "fork-ts-checker-webpack-plugin": "^3.1.1", 82 | "gsap": "^3.0.4", 83 | "prettier": "^1.19.1", 84 | "prop-types": "^15.7.2", 85 | "react": "^16.12.0", 86 | "react-dom": "^16.12.0", 87 | "release-it": "^12.4.3", 88 | "scrollmagic": "^2.0.7", 89 | "storybook": "^5.1.11", 90 | "styled-components": "^4.4.1", 91 | "ts-loader": "^6.2.1", 92 | "tsconfig-replace-paths": "^0.0.5", 93 | "tslint": "^5.20.1", 94 | "tslint-config-prettier": "^1.18.0", 95 | "tslint-eslint-rules": "^5.4.0", 96 | "types-installer": "^1.6.3", 97 | "typescript": "^3.7.4", 98 | "webpack": "^4.41.5" 99 | }, 100 | "peerDependencies": { 101 | "scrollmagic": "^2.0.7" 102 | }, 103 | "bugs": { 104 | "url": "https://github.com/jonkwheeler/ScrollScene/issues" 105 | }, 106 | "homepage": "https://github.com/jonkwheeler/ScrollScene#readme", 107 | "keywords": [ 108 | "scrollmagic", 109 | "scroll", 110 | "scrollscene", 111 | "gsap", 112 | "greensock" 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /src/ScrollObserver.ts: -------------------------------------------------------------------------------- 1 | import { errorLog, isFunc, isObject, scrollAnimationInit, createArray, isString, stringContains } from './helpers' 2 | 3 | const nameSpace = 'ScrollObserver' 4 | 5 | // @ts-ignore 6 | const state = function(visible, alreadyFired) { 7 | this.visible = false 8 | this.alreadyFired = false 9 | } 10 | 11 | const setClassName = function(this: any, options) { 12 | const toggle = { 13 | element: null, 14 | className: null, 15 | ...options, 16 | } 17 | 18 | if (!toggle.element) { 19 | errorLog( 20 | nameSpace, 21 | `Be sure to set a const toggleElement = (reactRef.current or document.querySelector) in the new ${nameSpace}({ toggle: { element: toggleElement } })`, 22 | ) 23 | } 24 | 25 | if (!toggle.className) { 26 | errorLog( 27 | nameSpace, 28 | `Be sure to set the className you want to toggle in the new ${nameSpace}({ toggle: { className: "my-class" } })`, 29 | ) 30 | } 31 | 32 | this.add = function() { 33 | /* add className if it's not already there */ 34 | !toggle.element.classList.contains(toggle.className) && toggle.element.classList.add(toggle.className) 35 | } 36 | 37 | this.remove = function() { 38 | /* remove className if it's there */ 39 | toggle.element.classList.contains(toggle.className) && toggle.element.classList.remove(toggle.className) 40 | } 41 | 42 | this.update = function(setState) { 43 | if (!setState.alreadyFired && setState.visible) { 44 | this.add() 45 | } 46 | 47 | if (setState.alreadyFired && !setState.visible) { 48 | this.remove() 49 | } 50 | } 51 | } 52 | 53 | const setTween = function(this: any, options) { 54 | const gsap = { 55 | timeline: null, 56 | yoyo: false, 57 | speed: 1, 58 | reverseSpeed: 1, 59 | delay: 2, 60 | ...options, 61 | } 62 | 63 | if (!gsap.timeline) { 64 | errorLog( 65 | nameSpace, 66 | `Be sure to set a const tl = gsap.timeline({ paused: true }) in the new ${nameSpace}({ gsap: { timeline: tl } })`, 67 | ) 68 | } 69 | 70 | const tl = gsap.timeline 71 | 72 | if (gsap.yoyo) { 73 | tl.repeat(-1) 74 | .yoyo(gsap.yoyo) 75 | .repeatDelay(gsap.delay) 76 | } 77 | 78 | this.play = function() { 79 | tl.timeScale(gsap.speed).play() 80 | } 81 | 82 | this.pause = function() { 83 | tl.pause() 84 | } 85 | 86 | this.reverse = function() { 87 | tl.timeScale(gsap.reverseSpeed).reverse() 88 | } 89 | 90 | this.kill = function() { 91 | if (tl) { 92 | tl.pause(0) 93 | tl.kill() 94 | } 95 | } 96 | 97 | this.update = function(setState) { 98 | if (!setState.alreadyFired && setState.visible) { 99 | this.play() 100 | } 101 | 102 | if (setState.alreadyFired && !setState.visible) { 103 | gsap.yoyo ? this.pause() : this.reverse() 104 | } 105 | } 106 | 107 | this.scrub = function(intersectionRatio) { 108 | tl.progress(intersectionRatio) 109 | } 110 | } 111 | 112 | const setPlayer = function(this: any, options) { 113 | const video = { 114 | element: null, 115 | playingClassName: null, 116 | pausedClassName: null, 117 | ...options, 118 | } 119 | 120 | if (!video.element) { 121 | errorLog( 122 | nameSpace, 123 | `Be sure to set a video element in the new ${nameSpace}({ video: { element: videoRef.current } })`, 124 | ) 125 | } 126 | 127 | function handlePlay() { 128 | if (video.element.src) { 129 | video.element.play() 130 | video.playingClassName && video.element.classList.add(video.playingClassName) 131 | video.pausedClassName && video.element.classList.remove(video.pausedClassName) 132 | } 133 | } 134 | function handlePause() { 135 | if (video.element.src) { 136 | video.element.pause() 137 | video.pausedClassName && video.element.classList.add(video.pausedClassName) 138 | video.playingClassName && video.element.classList.remove(video.playingClassName) 139 | } 140 | } 141 | 142 | function tryPlay() { 143 | try { 144 | handlePlay() 145 | } catch (error) { 146 | handlePause() 147 | } 148 | } 149 | 150 | function tryPause() { 151 | try { 152 | handlePause() 153 | } catch (error) {} 154 | } 155 | 156 | this.play = function() { 157 | tryPlay() 158 | } 159 | 160 | this.pause = function() { 161 | tryPause() 162 | } 163 | 164 | this.kill = function() { 165 | tryPause() 166 | } 167 | 168 | this.update = function(setState) { 169 | if (!setState.alreadyFired && setState.visible) { 170 | this.play() 171 | } 172 | 173 | if (setState.alreadyFired && !setState.visible) { 174 | this.pause() 175 | } 176 | } 177 | } 178 | 179 | const setFunction: any = function(this: any, options) { 180 | const callback = { 181 | active: null, 182 | notActive: null, 183 | ...options, 184 | } 185 | 186 | if (!callback.active && !callback.notActive) { 187 | errorLog( 188 | nameSpace, 189 | `Be sure to set a callback active or notActive function in the new ${nameSpace}({ callback: { active: () => () } })`, 190 | ) 191 | } 192 | 193 | if ((callback.active && !isFunc(callback.active)) || (callback.notActive && !isFunc(callback.notActive))) { 194 | errorLog(nameSpace, `Be sure to set the callback as a function `) 195 | } 196 | 197 | this.update = function(setState) { 198 | if (!setState.alreadyFired && setState.visible && callback.active) { 199 | callback.active() 200 | } 201 | 202 | if (setState.alreadyFired && !setState.visible && callback.notActive) { 203 | callback.notActive() 204 | } 205 | } 206 | } 207 | 208 | export interface IScrollObserverGsap { 209 | /** 210 | * timeline 211 | * @desc Insert your gsap.timeline object var 212 | * @type object 213 | * @example 214 | * const myTimeline = gsap.timeline() 215 | * gsap: { timeline: myTimeline } 216 | */ 217 | timeline: any 218 | 219 | /** 220 | * yoyo 221 | * @desc Do you want gsap animation to loop back and forth? 222 | * @type boolean 223 | * @default false 224 | * @example 225 | * gsap: { yoyo: true } 226 | */ 227 | yoyo?: boolean 228 | 229 | /** 230 | * speed 231 | * @desc The speed at which the gsap animation will play relative to it's current speed. 232 | * @type number 233 | * @default 1 234 | * @example 235 | * gsap: { speed: 2 } // = twice as fast 236 | */ 237 | speed?: number 238 | 239 | /** 240 | * speed 241 | * @desc The speed at which the gsap animation will reverse relative to it's current speed. 242 | * @type number 243 | * @default 4 244 | * @example 245 | * gsap: { speed: 4 } // = 4x as fast 246 | */ 247 | reverseSpeed?: number 248 | 249 | /** 250 | * delay 251 | * @desc The delay (in seconds) at which the gsap animation wait before reversing. 252 | * @type number 253 | * @default 2 254 | * @example 255 | * gsap: { delay: 3 } // = Wait 3 seconds 256 | */ 257 | delay?: number 258 | } 259 | 260 | export interface IScrollObserverToggle { 261 | /** 262 | * className 263 | * @desc Specify the className you wish to toggle. 264 | * @type string 265 | * @example 266 | * toggle: { className: 'turn-me-blue-baby' } 267 | */ 268 | className: string 269 | 270 | /** 271 | * element 272 | * @desc Specify the element you wish to toggle the className on. 273 | * @type HTMLElement | any 274 | * @example 275 | * toggle: { element: containerRef.current } 276 | */ 277 | element: HTMLElement | any 278 | } 279 | 280 | export interface IScrollObserverVideo { 281 | /** 282 | * element 283 | * @desc Specify the video element you wish to interact with. 284 | * @type HTMLElement | any 285 | * @example 286 | * video: { element: videoRef.current } 287 | */ 288 | element: HTMLElement | any 289 | } 290 | 291 | interface IScrollObserver { 292 | /** 293 | * breakpoints 294 | * @desc Use to set responsiveness of the ScrollObserver, mobile-first 295 | * @type object 296 | * @example 297 | * breakpoints: { 0: false, 768: true } 298 | */ 299 | breakpoints?: object 300 | 301 | /** 302 | * callback 303 | * @desc Use to set a callback when the scene is active. 304 | * @type object 305 | * @example 306 | * callback: { active: () => (), notActive: () => () } 307 | */ 308 | callback?: object 309 | 310 | /** 311 | * destroyImmediately 312 | * @desc Use to destroy the scene immediately after firing once the element is visible. 313 | * @type boolean 314 | * @example 315 | * destroyImmediately: true 316 | */ 317 | destroyImmediately?: boolean 318 | 319 | /** 320 | * gsap 321 | * @desc Use to set options for the gsap animation of the ScrollObserver. 322 | * @type object 323 | * @example 324 | * gsap: { timeline: myTimeline, yoyo: true, reverseSpeed: 2 } 325 | */ 326 | gsap?: IScrollObserverGsap 327 | 328 | /** 329 | * observer 330 | * @desc Extra options to pass the IntersectionObserver, like root, rootMargin, threshold (to override the thresholds option) 331 | * @type object 332 | * @example 333 | * observer: { rootMargin: '-50% 0%' } 334 | */ 335 | observer?: object 336 | 337 | /** 338 | * offset 339 | * @desc Change the offset. This uses rootMargin thus only works as a negative offset. 340 | * @type number | string 341 | * @defaultvalue '0% 0%' 342 | * @example 343 | * offset: 500 // this will be rootMargin: '-500px 0%' 344 | */ 345 | offset?: number | string 346 | 347 | /** 348 | * thresholds 349 | * @desc Set the number of thresholds you want. 350 | * @returns An Array of number from 0 to 1. Add 1 to your number to account for 0. 351 | * @type number 352 | * @example 353 | * thresholds: 1 = [0, 1] 354 | * thresholds: 2 = [0, 0.5, 1] 355 | * thresholds: 3 = [0, 0.33, 0.67, 1] 356 | * thresholds: 100 = [0, 0.1, 0.2, ... 0.98, 0.99, 1] 357 | */ 358 | thresholds?: number 359 | 360 | /** 361 | * toggle 362 | * @desc Use to set the options for the toggling of a className 363 | * @type object 364 | * @example 365 | * toggle: { element: containerRef.current, className: 'lets-do-this' } 366 | */ 367 | toggle?: IScrollObserverToggle 368 | 369 | /** 370 | * triggerElement 371 | * @desc Set the element you wish to trigger events based upon, the observed element. 372 | * @type HTMLElement | any 373 | * @example 374 | * triggerElement: triggerRef.current 375 | */ 376 | triggerElement: HTMLElement | any 377 | 378 | /** 379 | * useDuration 380 | * @desc Use the percentage of element visibility to scrub the gsap timeline. 381 | * @type boolean 382 | * @example 383 | * useDuration: true 384 | */ 385 | useDuration?: boolean 386 | 387 | /** 388 | * video 389 | * @desc Use to set the options for playing and pausing of the video. 390 | * @type object 391 | * @example 392 | * video: { element: videoRef.current } 393 | */ 394 | video?: IScrollObserverVideo 395 | 396 | /** 397 | * whenVisible 398 | * @desc Set when the scene should be active based on the percentage of the element visible 399 | * @type number 400 | * @example 401 | * whenVisible: '50%' 402 | */ 403 | whenVisible?: string 404 | } 405 | 406 | const ScrollObserver = function( 407 | this: any, 408 | { 409 | breakpoints, 410 | callback, 411 | destroyImmediately, 412 | gsap, 413 | observer, 414 | offset, 415 | whenVisible, 416 | thresholds, 417 | toggle, 418 | triggerElement, 419 | useDuration, 420 | video, 421 | }: IScrollObserver, 422 | ) { 423 | if (!triggerElement) { 424 | errorLog( 425 | nameSpace, 426 | 'Be sure to set a const triggerElement = (reactRef.current or document.querySelector) in the new ScrollScene({ triggerElement: triggerElement })', 427 | ) 428 | } 429 | 430 | const $this = this 431 | let setToggle 432 | let setGsap 433 | let setVideo 434 | let setCallback 435 | let ratio 436 | let setRootMargin = '0% 0%' 437 | let setState = new state(false, false) 438 | 439 | if (typeof offset === 'number') { 440 | // protect against positive px values, for now 441 | setRootMargin = `-${Math.abs(offset)}px 0%` 442 | } else if (typeof offset === 'string') { 443 | // protect against positive percentage values, for now 444 | setRootMargin = `-${Math.abs(parseFloat(offset))}% 0%` 445 | } 446 | 447 | if (toggle && isObject(toggle)) { 448 | setToggle = new setClassName(toggle) 449 | } 450 | 451 | if (gsap && isObject(gsap)) { 452 | setGsap = new setTween(gsap) 453 | } 454 | 455 | if (video && isObject(video)) { 456 | setVideo = new setPlayer(video) 457 | } 458 | 459 | if (callback && isObject(callback)) { 460 | setCallback = new setFunction(callback) 461 | } 462 | 463 | const observerCallback = function(entries) { 464 | entries.forEach(({ isIntersecting, intersectionRatio }) => { 465 | /* 466 | * To help the wonkiness of IntersectionObserver, isIntersecting firing true when it's really false 467 | */ 468 | if (ratio) { 469 | setState.visible = intersectionRatio >= ratio 470 | } else if (isIntersecting && !setState.visible) { 471 | /* 472 | * To help with setCallback and ignoring refiring extra function 473 | * calls due to intersectionRatio 474 | */ 475 | setState.visible = true 476 | } else if (!isIntersecting && setState.visible) { 477 | setState.visible = false 478 | } 479 | 480 | setToggle && setToggle.update(setState) 481 | setGsap && (!useDuration ? setGsap.update(setState) : setGsap.scrub(intersectionRatio)) 482 | setVideo && setVideo.update(setState) 483 | setCallback && setCallback.update(setState) 484 | 485 | /* 486 | * To help with ignoring refiring extra function 487 | */ 488 | if (!setState.alreadyFired && setState.visible) { 489 | setState.alreadyFired = true 490 | } 491 | 492 | if (setState.alreadyFired && !setState.visible) { 493 | setState.alreadyFired = false 494 | } 495 | 496 | if (isIntersecting && destroyImmediately) { 497 | /* 498 | * destroy the scene after used once 499 | */ 500 | $this.destroy() 501 | } 502 | }) 503 | } 504 | 505 | const getPercentage = value => { 506 | if (!isString(whenVisible) && !stringContains(whenVisible, '%')) { 507 | errorLog(nameSpace, 'Be sure to set a percentage as a string. { whenVisible: "50%" }') 508 | } 509 | 510 | const parsed = parseInt(value.replace('%', '')) / 100 511 | 512 | ratio = parsed 513 | 514 | return parsed 515 | } 516 | 517 | const getThresolds = () => { 518 | const defaults = { 519 | one: [0, 1], 520 | gsap: createArray(199), 521 | } 522 | 523 | let returnedThresholds: any = defaults.one 524 | 525 | if (whenVisible) { 526 | returnedThresholds = getPercentage(whenVisible) 527 | } 528 | 529 | if (useDuration) { 530 | returnedThresholds = defaults.gsap 531 | } 532 | 533 | if (thresholds) { 534 | returnedThresholds = createArray(thresholds) 535 | } 536 | 537 | return returnedThresholds 538 | } 539 | 540 | const Observer = new IntersectionObserver(observerCallback, { 541 | threshold: getThresolds(), 542 | rootMargin: setRootMargin, 543 | ...observer, 544 | }) 545 | 546 | this.init = function() { 547 | Observer.observe(triggerElement) 548 | } 549 | 550 | this.destroy = function() { 551 | if (triggerElement && Observer) { 552 | if (setToggle) { 553 | setToggle.remove() 554 | } 555 | 556 | if (setGsap) { 557 | setGsap.kill() 558 | } 559 | 560 | if (setVideo) { 561 | setVideo.kill() 562 | } 563 | 564 | Observer.unobserve(triggerElement) 565 | } 566 | } 567 | 568 | scrollAnimationInit(breakpoints, this.init, this.destroy) 569 | } 570 | 571 | export { ScrollObserver } 572 | -------------------------------------------------------------------------------- /src/ScrollScene.ts: -------------------------------------------------------------------------------- 1 | import ScrollMagic from './scrollmagic-with-ssr' 2 | import throttle from 'lodash.throttle' 3 | import { errorLog, isObject, scrollAnimationInit } from './helpers' 4 | import { IScrollObserverToggle, IScrollObserverGsap } from './ScrollObserver' 5 | 6 | const nameSpace = 'ScrollScene' 7 | 8 | const updateTweenProgress = function(Scene, Tween, gsapForwardSpeed, gsapReverseSpeed) { 9 | if (Tween) { 10 | const progress = Scene.progress() 11 | const state = Scene.state() 12 | if (Tween.repeat && Tween.repeat() === -1) { 13 | // infinite loop, so not in relation to progress 14 | if (state === 'DURING' && Tween.paused()) { 15 | Tween.timeScale(gsapForwardSpeed).play() 16 | } else if (state !== 'DURING' && !Tween.paused()) { 17 | Tween.pause() 18 | } 19 | } else if (progress != Tween.progress()) { 20 | // do we even need to update the progress? 21 | // no infinite loop - so should we just play or go to a specific point in time? 22 | if (Scene.duration() === 0) { 23 | // play the animation 24 | if (progress > 0) { 25 | // play from 0 to 1 26 | Tween.timeScale(gsapForwardSpeed).play() 27 | } else { 28 | // play from 1 to 0 29 | Tween.timeScale(gsapReverseSpeed).reverse() 30 | } 31 | } else { 32 | // go to a specific point in time 33 | // just hard set it 34 | Tween.progress(progress).pause() 35 | } 36 | } 37 | } 38 | } 39 | 40 | const removeTween = function(Tween) { 41 | if (Tween) { 42 | Tween.pause(0) 43 | Tween.kill() 44 | } 45 | } 46 | 47 | const setDuration = (Scene, duration) => { 48 | /* check if duration is set as an HTMLElement */ 49 | if (duration instanceof HTMLElement) { 50 | let previousHeight 51 | let currentHeight 52 | 53 | const getHeight = () => duration.offsetHeight 54 | 55 | const update = () => { 56 | Scene.duration(getHeight()) 57 | previousHeight = getHeight() 58 | } 59 | 60 | const fn = () => { 61 | /* set duration to match height of element */ 62 | currentHeight = getHeight() 63 | 64 | if (currentHeight !== previousHeight) { 65 | update() 66 | } 67 | } 68 | 69 | fn() 70 | 71 | window.addEventListener('resize', throttle(fn, 700)) 72 | 73 | currentHeight = getHeight() 74 | 75 | update() 76 | } else if (isObject(duration)) { 77 | /* if an object, make breakpoints */ 78 | const keys = Object.keys(duration).reverse() 79 | 80 | const fn = () => { 81 | for (let index = 0; index < keys.length; index++) { 82 | const breakpoint = parseFloat(keys[index]) 83 | 84 | if (breakpoint <= window.innerWidth) { 85 | Scene.duration(duration[breakpoint]) 86 | break 87 | } 88 | } 89 | } 90 | 91 | fn() 92 | 93 | window.addEventListener('resize', throttle(fn, 700)) 94 | } else { 95 | /* nothing of the above? just set it */ 96 | Scene.duration(duration) 97 | } 98 | } 99 | 100 | const setClassName = (Scene, options, duration) => { 101 | const toggle = { 102 | className: null, 103 | element: null, 104 | reverse: false, 105 | ...options, 106 | } 107 | 108 | if (!toggle.className) { 109 | errorLog(nameSpace, `Be sure to set a className in the new ${nameSpace}({ toggle: { className: "my-class" } })`) 110 | } 111 | 112 | if (!toggle.element) { 113 | errorLog( 114 | nameSpace, 115 | `Be sure to set a const toggleElement = (reactRef.current or document.querySelector) in the new ${nameSpace}({ toggle: { element: toggleElement } })`, 116 | ) 117 | } 118 | 119 | const addClassName = () => 120 | !toggle.element.classList.contains(toggle.className) && toggle.element.classList.add(toggle.className) 121 | 122 | const removeClassName = () => 123 | toggle.element.classList.contains(toggle.className) && toggle.element.classList.remove(toggle.className) 124 | 125 | Scene.on('enter', function() { 126 | addClassName() 127 | }) 128 | 129 | Scene.on('add', function() { 130 | if (Scene.state() === 'DURING') { 131 | addClassName() 132 | } 133 | }) 134 | 135 | Scene.on('leave', function(event) { 136 | if (!toggle.reverse && duration) { 137 | // needs to be based on whether or not we have a duration 138 | event.scrollDirection === 'REVERSE' && removeClassName() 139 | } else { 140 | removeClassName() 141 | } 142 | }) 143 | 144 | Scene.on('remove', function() { 145 | removeClassName() 146 | }) 147 | } 148 | 149 | const setTween = (Scene, options) => { 150 | const gsap = { 151 | forwardSpeed: 1, 152 | reverseSpeed: 1, 153 | timeline: null, 154 | ...options, 155 | } 156 | 157 | if (!gsap.timeline) { 158 | errorLog( 159 | nameSpace, 160 | `Be sure to set a const tl = gsap.timeline({ paused: true }) in the new ${nameSpace}({ gsap: { timeline: tl } })`, 161 | ) 162 | } 163 | 164 | Scene.on('progress', function() { 165 | updateTweenProgress(Scene, gsap.timeline, gsap.forwardSpeed, gsap.reverseSpeed) 166 | }) 167 | 168 | Scene.on('remove', function() { 169 | removeTween(gsap.timeline) 170 | }) 171 | } 172 | 173 | interface IScrollSceneToggle extends IScrollObserverToggle { 174 | /** 175 | * reverse 176 | * @desc Specify the className should be removed after the duration of scene is met. Only applies if scene has duration. 177 | * @type boolean 178 | * @default false 179 | * @example 180 | * toggle: { reverse: true } 181 | */ 182 | reverse?: boolean 183 | } 184 | 185 | interface IScrollScene { 186 | /** 187 | * breakpoints 188 | * @desc Use to set responsiveness of the new ScrollMagic.Scene, mobile-first 189 | * @type object 190 | * @example 191 | * breakpoints: { 0: false, 768: true } 192 | */ 193 | breakpoints?: object 194 | 195 | /** 196 | * controller 197 | * @desc Extra options to pass the new ScrollMagic.Controller, like vertical, etc. 198 | * @type object 199 | * @example 200 | * controller: { vertical: false } 201 | */ 202 | controller?: object 203 | 204 | /** 205 | * duration 206 | * @desc Use to set responsiveness of the new ScrollMagic.Scene, mobile-first (if setting breakpoints) 207 | * Must be string for percentage, and number for pixel. 208 | * @type object 209 | * @example 210 | * duration: '100%' = 100vh 211 | * duration: 100 = 100px 212 | * duration: { 0: '50%', 768: '100% } // = ScrollScene lasts 50vh on mobile, 100% after 213 | */ 214 | duration?: string | number | object 215 | 216 | /** 217 | * gsap 218 | * @desc Use to set options for the gsap animation of the ScrollObserver. 219 | * @type object 220 | * @example 221 | * gsap: { timeline: myTimeline, yoyo: true, reverseSpeed: 2 } 222 | */ 223 | gsap?: IScrollObserverGsap 224 | 225 | /** 226 | * triggerHook 227 | * @desc Set the offset of the ScrollMagic scene. 228 | * @type number | string 229 | * @defaultvalue 0 230 | * @example 231 | * offset: 100 232 | * offset: '10%' 233 | */ 234 | offset?: number | string 235 | 236 | /** 237 | * scene 238 | * @desc Extra options to pass the new ScrollMagic.Scene, like logLevel, etc. 239 | * @type object 240 | * @example 241 | * scene: { logLevel: 2 } 242 | */ 243 | scene?: object 244 | 245 | /** 246 | * toggle 247 | * @desc Use to set the options for the toggling of a className 248 | * @type object 249 | * @example 250 | * toggle: { element: containerRef.current, className: 'lets-do-this' } 251 | */ 252 | toggle?: IScrollSceneToggle 253 | 254 | /** 255 | * triggerElement 256 | * @desc Set the element you wish to trigger events based upon, the observed element. 257 | * @type HTMLElement | any 258 | * @example 259 | * triggerElement: triggerRef.current 260 | */ 261 | triggerElement: HTMLElement | any 262 | 263 | /** 264 | * triggerHook 265 | * @desc Set the triggerHook of the ScrollMagic scene. 266 | * @type number 267 | * @defaultvalue 'onEnter' 268 | * @example 269 | * triggerHook: 0.5 270 | */ 271 | triggerHook?: number | string 272 | 273 | /** 274 | * useGlobalController 275 | * @desc Chose whether or not to use the globalController provided for you, or a fresh new ScrollMagic.Controller instance. 276 | * @type boolean 277 | * @defaultValue true 278 | * @example 279 | * useGlobalController: false 280 | */ 281 | useGlobalController?: boolean 282 | } 283 | 284 | // add controller var 285 | let globalController 286 | 287 | const ScrollScene = function( 288 | this: any, 289 | { 290 | breakpoints, 291 | controller = {}, 292 | duration, 293 | gsap, 294 | offset = 0, 295 | scene = {}, 296 | toggle, 297 | triggerElement, 298 | triggerHook = 'onEnter', 299 | useGlobalController = true, 300 | }: IScrollScene, 301 | ) { 302 | let localController 303 | 304 | // check if using a local controller 305 | if (!useGlobalController) { 306 | localController = new ScrollMagic.Controller(controller) 307 | } 308 | 309 | // mount controller 310 | if (!globalController && useGlobalController) { 311 | globalController = new ScrollMagic.Controller(controller) 312 | } 313 | 314 | const controllerIsUse = localController ? localController : globalController 315 | 316 | if (!triggerElement) { 317 | errorLog( 318 | nameSpace, 319 | `Be sure to set a const triggerElement = (reactRef.current or document.querySelector) in the new ${nameSpace}({ triggerElement: triggerElement }).`, 320 | ) 321 | } 322 | 323 | const Scene = new ScrollMagic.Scene({ 324 | triggerElement, 325 | triggerHook, 326 | offset, 327 | ...scene, 328 | }) 329 | 330 | if (duration) { 331 | setDuration(Scene, duration) 332 | } 333 | 334 | if (toggle && isObject(toggle)) { 335 | setClassName(Scene, toggle, duration) 336 | } 337 | 338 | if (gsap && isObject(gsap)) { 339 | setTween(Scene, gsap) 340 | } 341 | 342 | this.init = function() { 343 | controllerIsUse && Scene.addTo(controllerIsUse) 344 | } 345 | 346 | this.destroy = function() { 347 | Scene.remove() 348 | } 349 | 350 | this.Scene = Scene 351 | this.Controller = controllerIsUse 352 | 353 | scrollAnimationInit(breakpoints, this.init, this.destroy) 354 | } 355 | 356 | export { ScrollScene } 357 | -------------------------------------------------------------------------------- /src/helpers/createArray.ts: -------------------------------------------------------------------------------- 1 | /* 2 | * createArray 3 | * Set performance using thresholds 4 | * @desc Building an array of numbers starting at 0.00 and incrementing at every 0.01 5 | * @example 6 | * thresholds = 2 [0, 0.5, 1], thresholds = 3 [0, 0.33, 0.67, 1] 7 | */ 8 | import { roundOff } from './roundOff' 9 | 10 | export const createArray = value => 11 | Array.apply(null, new Array(value + 1)) 12 | // @ts-ignore 13 | .map((n, i) => roundOff(i / value)) 14 | -------------------------------------------------------------------------------- /src/helpers/errorLog.ts: -------------------------------------------------------------------------------- 1 | export const errorLog = (nameSpace, msg) => { 2 | throw new Error(`${nameSpace} -> ${msg}`) 3 | } 4 | -------------------------------------------------------------------------------- /src/helpers/index.ts: -------------------------------------------------------------------------------- 1 | export { createArray } from './createArray' 2 | export { errorLog } from './errorLog' 3 | export { isFunc } from './isFunc' 4 | export { isObject } from './isObject' 5 | export { isString } from './isString' 6 | export { roundOff } from './roundOff' 7 | export { scrollAnimationInit } from './scrollAnimationInit' 8 | export { stringContains } from './stringContains' 9 | -------------------------------------------------------------------------------- /src/helpers/isFunc.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * isFunc 3 | * @returns boolean 4 | * @example 5 | * isFunc(function(){}) => true 6 | * isFunc('hi') => false 7 | */ 8 | export const isFunc = value => typeof value === 'function' 9 | -------------------------------------------------------------------------------- /src/helpers/isObject.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * isObject 3 | * @returns boolean 4 | * @example 5 | * isObject({foo: 'bar'}) => true 6 | * isObject('yo') => false 7 | */ 8 | export const isObject = x => typeof x === 'object' && !Array.isArray(x) && x !== null 9 | -------------------------------------------------------------------------------- /src/helpers/isString.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * isString 3 | * @returns boolean 4 | * @example 5 | * isString('yo') => true 6 | * isString({foo: 'bar'}) => false 7 | */ 8 | export const isString = x => typeof x === 'string' 9 | -------------------------------------------------------------------------------- /src/helpers/roundOff.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * roundOff 3 | * @desc round a number off to nearest 2 decimal places 4 | * roundOff(5) => 5 5 | * roundOff(100) => 100 6 | * roundOff(0.23432) => 0.23 7 | * roundOff(0.897) => 0.9 8 | */ 9 | 10 | export const roundOff = value => Math.round(value * 100) / 100 11 | -------------------------------------------------------------------------------- /src/helpers/scrollAnimationInit.ts: -------------------------------------------------------------------------------- 1 | import throttle from 'lodash.throttle' 2 | import { isObject } from './isObject' 3 | 4 | export const scrollAnimationInit = (breakpoints, init, destroy) => { 5 | if (isObject(breakpoints)) { 6 | const keys = Object.keys(breakpoints).reverse() 7 | 8 | const fn = () => { 9 | for (let index = 0; index < keys.length; index += 1) { 10 | const breakpoint = parseFloat(keys[index]) 11 | 12 | if (breakpoint <= window.innerWidth) { 13 | if (breakpoints[breakpoint]) { 14 | init() 15 | } else { 16 | destroy() 17 | } 18 | 19 | break 20 | } 21 | } 22 | } 23 | 24 | fn() 25 | 26 | window.addEventListener('resize', throttle(fn, 700)) 27 | } else { 28 | init() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/helpers/stringContains.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * stringContains 3 | * @returns boolean 4 | * @example 5 | * stringContains('yoyoyo', 'yo') => true 6 | * stringContains('Hello', 'World') => false 7 | */ 8 | export const stringContains = (doesThis: string, containThis: string) => doesThis.indexOf(containThis) !== -1 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { ScrollScene } from './ScrollScene' 2 | export { ScrollObserver } from './ScrollObserver' 3 | 4 | import scrollmagic from './scrollmagic-with-ssr' 5 | export { scrollmagic as ScrollMagic } 6 | export { scrollmagic as ScrollMagicSsr } 7 | 8 | import addindicators from './scrollmagic-add-indicators' 9 | export { addindicators as addIndicators } 10 | -------------------------------------------------------------------------------- /src/plugins/debug.addIndicators.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /* prettier-ignore */ 3 | 4 | /* 5 | Slightly modified version of this: https://github.com/janpaepke/ScrollMagic/blob/master/dev/src/plugins/debug.addIndicators.js 6 | */ 7 | 8 | import ScrollMagic from '../scrollmagic-with-ssr' 9 | 10 | var NAMESPACE = 'debug.addIndicators' 11 | 12 | var console = window.console || {}, 13 | //@ts-ignore 14 | err = Function.prototype.bind.call(console.error || console.log || function() {}, console) 15 | if (!ScrollMagic) { 16 | err( 17 | '(' + 18 | NAMESPACE + 19 | ") -> ERROR: The ScrollMagic main module could not be found. Please make sure it's loaded before this plugin or use an asynchronous loader like requirejs.", 20 | ) 21 | } 22 | 23 | // plugin settings 24 | var FONT_SIZE = '0.85em', 25 | ZINDEX = '9999', 26 | EDGE_OFFSET = 15 // minimum edge distance, added to indentation 27 | 28 | // overall vars 29 | var _util = ScrollMagic._util, 30 | _autoindex = 0 31 | 32 | ScrollMagic.Scene.extend(function() { 33 | var Scene = this, 34 | _indicator 35 | //@ts-ignore 36 | var log = function() { 37 | if (Scene._log) { 38 | // not available, when main source minified 39 | Array.prototype.splice.call(arguments, 1, 0, '(' + NAMESPACE + ')', '->') 40 | Scene._log.apply(this, arguments) 41 | } 42 | } 43 | 44 | /** 45 | * Add visual indicators for a ScrollMagic.Scene. 46 | * @memberof! debug.addIndicators# 47 | * 48 | * @example 49 | * // add basic indicators 50 | * scene.addIndicators() 51 | * 52 | * // passing options 53 | * scene.addIndicators({name: "pin scene", colorEnd: "#FFFFFF"}); 54 | * 55 | * @param {object} [options] - An object containing one or more options for the indicators. 56 | * @param {(string|object)} [options.parent] - A selector, DOM Object or a jQuery object that the indicators should be added to. 57 | If undefined, the controller's container will be used. 58 | * @param {number} [options.name=""] - This string will be displayed at the start and end indicators of the scene for identification purposes. If no name is supplied an automatic index will be used. 59 | * @param {number} [options.indent=0] - Additional position offset for the indicators (useful, when having multiple scenes starting at the same position). 60 | * @param {string} [options.colorStart=green] - CSS color definition for the start indicator. 61 | * @param {string} [options.colorEnd=red] - CSS color definition for the end indicator. 62 | * @param {string} [options.colorTrigger=blue] - CSS color definition for the trigger indicator. 63 | */ 64 | Scene.addIndicators = function(options) { 65 | if (!_indicator) { 66 | var DEFAULT_OPTIONS = { 67 | name: '', 68 | indent: 0, 69 | parent: undefined, 70 | colorStart: 'green', 71 | colorEnd: 'red', 72 | colorTrigger: 'blue', 73 | } 74 | 75 | options = _util.extend({}, DEFAULT_OPTIONS, options) 76 | 77 | _autoindex++ 78 | _indicator = new Indicator(Scene, options) 79 | 80 | Scene.on('add.plugin_addIndicators', _indicator.add) 81 | Scene.on('remove.plugin_addIndicators', _indicator.remove) 82 | Scene.on('destroy.plugin_addIndicators', Scene.removeIndicators) 83 | 84 | // it the scene already has a controller we can start right away. 85 | if (Scene.controller()) { 86 | _indicator.add() 87 | } 88 | } 89 | return Scene 90 | } 91 | 92 | /** 93 | * Removes visual indicators from a ScrollMagic.Scene. 94 | * @memberof! debug.addIndicators# 95 | * 96 | * @example 97 | * // remove previously added indicators 98 | * scene.removeIndicators() 99 | * 100 | */ 101 | Scene.removeIndicators = function() { 102 | if (_indicator) { 103 | _indicator.remove() 104 | this.off('*.plugin_addIndicators') 105 | _indicator = undefined 106 | } 107 | return Scene 108 | } 109 | }) 110 | 111 | /* 112 | * ---------------------------------------------------------------- 113 | * Extension for controller to store and update related indicators 114 | * ---------------------------------------------------------------- 115 | */ 116 | // add option to globally auto-add indicators to scenes 117 | /** 118 | * Every ScrollMagic.Controller instance now accepts an additional option. 119 | * See {@link ScrollMagic.Controller} for a complete list of the standard options. 120 | * @memberof! debug.addIndicators# 121 | * @method new ScrollMagic.Controller(options) 122 | * @example 123 | * // make a controller and add indicators to all scenes attached 124 | * var controller = new ScrollMagic.Controller({addIndicators: true}); 125 | * // this scene will automatically have indicators added to it 126 | * new ScrollMagic.Scene() 127 | * .addTo(controller); 128 | * 129 | * @param {object} [options] - Options for the Controller. 130 | * @param {boolean} [options.addIndicators=false] - If set to `true` every scene that is added to the controller will automatically get indicators added to it. 131 | */ 132 | ScrollMagic.Controller.addOption('addIndicators', false) 133 | // extend Controller 134 | ScrollMagic.Controller.extend(function() { 135 | var Controller = this, 136 | _info = Controller.info(), 137 | _container = _info.container, 138 | _isDocument = _info.isDocument, 139 | _vertical = _info.vertical, 140 | _indicators = { 141 | // container for all indicators and methods 142 | groups: [], 143 | } 144 | 145 | var log = function() { 146 | if (Controller._log) { 147 | // not available, when main source minified 148 | Array.prototype.splice.call(arguments, 1, 0, '(' + NAMESPACE + ')', '->') 149 | Controller._log.apply(this, arguments) 150 | } 151 | } 152 | if (Controller._indicators) { 153 | //@ts-ignore 154 | log(2, "WARNING: Scene already has a property '_indicators', which will be overwritten by plugin.") 155 | } 156 | 157 | // add indicators container 158 | this._indicators = _indicators 159 | /* 160 | needed updates: 161 | +++++++++++++++ 162 | start/end position on scene shift (handled in Indicator class) 163 | trigger parameters on triggerHook value change (handled in Indicator class) 164 | bounds position on container scroll or resize (to keep alignment to bottom/right) 165 | trigger position on container resize, window resize (if container isn't document) and window scroll (if container isn't document) 166 | */ 167 | 168 | // event handler for when associated bounds markers need to be repositioned 169 | var handleBoundsPositionChange = function() { 170 | //@ts-ignore 171 | _indicators.updateBoundsPositions() 172 | } 173 | 174 | // event handler for when associated trigger groups need to be repositioned 175 | var handleTriggerPositionChange = function() { 176 | //@ts-ignore 177 | _indicators.updateTriggerGroupPositions() 178 | } 179 | 180 | _container.addEventListener('resize', handleTriggerPositionChange) 181 | if (!_isDocument) { 182 | window.addEventListener('resize', handleTriggerPositionChange) 183 | window.addEventListener('scroll', handleTriggerPositionChange) 184 | } 185 | // update all related bounds containers 186 | _container.addEventListener('resize', handleBoundsPositionChange) 187 | _container.addEventListener('scroll', handleBoundsPositionChange) 188 | 189 | // updates the position of the bounds container to aligned to the right for vertical containers and to the bottom for horizontal 190 | this._indicators.updateBoundsPositions = function(specificIndicator) { 191 | var // constant for all bounds 192 | groups = specificIndicator 193 | ? [ 194 | _util.extend({}, specificIndicator.triggerGroup, { 195 | members: [specificIndicator], 196 | }), 197 | ] // create a group with only one element 198 | : _indicators.groups, // use all 199 | g = groups.length, 200 | css = {}, 201 | paramPos = _vertical ? 'left' : 'top', 202 | paramDimension = _vertical ? 'width' : 'height', 203 | edge = _vertical 204 | ? _util.get.scrollLeft(_container) + _util.get.width(_container) - EDGE_OFFSET 205 | : _util.get.scrollTop(_container) + _util.get.height(_container) - EDGE_OFFSET, 206 | b, 207 | triggerSize, 208 | group 209 | while (g--) { 210 | // group loop 211 | group = groups[g] 212 | b = group.members.length 213 | triggerSize = _util.get[paramDimension](group.element.firstChild) 214 | while (b--) { 215 | // indicators loop 216 | css[paramPos] = edge - triggerSize 217 | _util.css(group.members[b].bounds, css) 218 | } 219 | } 220 | } 221 | 222 | // updates the positions of all trigger groups attached to a controller or a specific one, if provided 223 | this._indicators.updateTriggerGroupPositions = function(specificGroup) { 224 | var // constant vars 225 | groups = specificGroup ? [specificGroup] : _indicators.groups, 226 | i = groups.length, 227 | container = _isDocument ? document.body : _container, 228 | containerOffset = _isDocument 229 | ? { 230 | top: 0, 231 | left: 0, 232 | } 233 | : _util.get.offset(container, true), 234 | edge = _vertical ? _util.get.width(_container) - EDGE_OFFSET : _util.get.height(_container) - EDGE_OFFSET, 235 | paramDimension = _vertical ? 'width' : 'height', 236 | paramTransform = _vertical ? 'Y' : 'X' 237 | var // changing vars 238 | group, 239 | elem, 240 | pos, 241 | elemSize, 242 | transform 243 | while (i--) { 244 | group = groups[i] 245 | elem = group.element 246 | pos = group.triggerHook * Controller.info('size') 247 | elemSize = _util.get[paramDimension](elem.firstChild.firstChild) 248 | transform = pos > elemSize ? 'translate' + paramTransform + '(-100%)' : '' 249 | 250 | _util.css(elem, { 251 | top: containerOffset.top + (_vertical ? pos : edge - group.members[0].options.indent), 252 | left: containerOffset.left + (_vertical ? edge - group.members[0].options.indent : pos), 253 | }) 254 | _util.css(elem.firstChild.firstChild, { 255 | '-ms-transform': transform, 256 | '-webkit-transform': transform, 257 | transform: transform, 258 | }) 259 | } 260 | } 261 | 262 | // updates the label for the group to contain the name, if it only has one member 263 | this._indicators.updateTriggerGroupLabel = function(group) { 264 | var text = 'trigger' + (group.members.length > 1 ? '' : ' ' + group.members[0].options.name), 265 | elem = group.element.firstChild.firstChild, 266 | doUpdate = elem.textContent !== text 267 | if (doUpdate) { 268 | elem.textContent = text 269 | if (_vertical) { 270 | // bounds position is dependent on text length, so update 271 | //@ts-ignore 272 | _indicators.updateBoundsPositions() 273 | } 274 | } 275 | } 276 | 277 | // add indicators if global option is set 278 | this.addScene = function(newScene) { 279 | if (this._options.addIndicators && newScene instanceof ScrollMagic.Scene && newScene.controller() === Controller) { 280 | newScene.addIndicators() 281 | } 282 | // call original destroy method 283 | this.$super.addScene.apply(this, arguments) 284 | } 285 | 286 | // remove all previously set listeners on destroy 287 | this.destroy = function() { 288 | _container.removeEventListener('resize', handleTriggerPositionChange) 289 | if (!_isDocument) { 290 | window.removeEventListener('resize', handleTriggerPositionChange) 291 | window.removeEventListener('scroll', handleTriggerPositionChange) 292 | } 293 | _container.removeEventListener('resize', handleBoundsPositionChange) 294 | _container.removeEventListener('scroll', handleBoundsPositionChange) 295 | // call original destroy method 296 | this.$super.destroy.apply(this, arguments) 297 | } 298 | return Controller 299 | }) 300 | 301 | /* 302 | * ---------------------------------------------------------------- 303 | * Internal class for the construction of Indicators 304 | * ---------------------------------------------------------------- 305 | */ 306 | var Indicator = function(Scene, options) { 307 | var Indicator = this, 308 | _elemBounds = TPL.bounds(), 309 | _elemStart = TPL.start(options.colorStart), 310 | _elemEnd = TPL.end(options.colorEnd), 311 | _boundsContainer = options.parent && _util.get.elements(options.parent)[0], 312 | _vertical, 313 | _ctrl 314 | 315 | var log = function() { 316 | if (Scene._log) { 317 | // not available, when main source minified 318 | Array.prototype.splice.call(arguments, 1, 0, '(' + NAMESPACE + ')', '->') 319 | Scene._log.apply(this, arguments) 320 | } 321 | } 322 | 323 | options.name = options.name || _autoindex 324 | 325 | // prepare bounds elements 326 | _elemStart.firstChild.textContent += ' ' + options.name 327 | _elemEnd.textContent += ' ' + options.name 328 | _elemBounds.appendChild(_elemStart) 329 | _elemBounds.appendChild(_elemEnd) 330 | 331 | // set public variables 332 | Indicator.options = options 333 | Indicator.bounds = _elemBounds 334 | // will be set later 335 | Indicator.triggerGroup = undefined 336 | 337 | // add indicators to DOM 338 | this.add = function() { 339 | _ctrl = Scene.controller() 340 | _vertical = _ctrl.info('vertical') 341 | 342 | var isDocument = _ctrl.info('isDocument') 343 | 344 | if (!_boundsContainer) { 345 | // no parent supplied or doesnt exist 346 | _boundsContainer = isDocument ? document.body : _ctrl.info('container') // check if window/document (then use body) 347 | } 348 | if (!isDocument && _util.css(_boundsContainer, 'position') === 'static') { 349 | // position mode needed for correct positioning of indicators 350 | _util.css(_boundsContainer, { 351 | position: 'relative', 352 | }) 353 | } 354 | 355 | // add listeners for updates 356 | Scene.on('change.plugin_addIndicators', handleTriggerParamsChange) 357 | Scene.on('shift.plugin_addIndicators', handleBoundsParamsChange) 358 | 359 | // updates trigger & bounds (will add elements if needed) 360 | updateTriggerGroup() 361 | updateBounds() 362 | 363 | setTimeout(function() { 364 | // do after all execution is finished otherwise sometimes size calculations are off 365 | _ctrl._indicators.updateBoundsPositions(Indicator) 366 | }, 0) 367 | //@ts-ignore 368 | log(3, 'added indicators') 369 | } 370 | 371 | // remove indicators from DOM 372 | this.remove = function() { 373 | if (Indicator.triggerGroup) { 374 | // if not set there's nothing to remove 375 | Scene.off('change.plugin_addIndicators', handleTriggerParamsChange) 376 | Scene.off('shift.plugin_addIndicators', handleBoundsParamsChange) 377 | 378 | if (Indicator.triggerGroup.members.length > 1) { 379 | // just remove from memberlist of old group 380 | var group = Indicator.triggerGroup 381 | group.members.splice(group.members.indexOf(Indicator), 1) 382 | _ctrl._indicators.updateTriggerGroupLabel(group) 383 | _ctrl._indicators.updateTriggerGroupPositions(group) 384 | Indicator.triggerGroup = undefined 385 | } else { 386 | // remove complete group 387 | removeTriggerGroup() 388 | } 389 | removeBounds() 390 | //@ts-ignore 391 | log(3, 'removed indicators') 392 | } 393 | } 394 | 395 | /* 396 | * ---------------------------------------------------------------- 397 | * internal Event Handlers 398 | * ---------------------------------------------------------------- 399 | */ 400 | 401 | // event handler for when bounds params change 402 | var handleBoundsParamsChange = function() { 403 | updateBounds() 404 | } 405 | 406 | // event handler for when trigger params change 407 | var handleTriggerParamsChange = function(e) { 408 | if (e.what === 'triggerHook') { 409 | updateTriggerGroup() 410 | } 411 | } 412 | 413 | /* 414 | * ---------------------------------------------------------------- 415 | * Bounds (start / stop) management 416 | * ---------------------------------------------------------------- 417 | */ 418 | 419 | // adds an new bounds elements to the array and to the DOM 420 | var addBounds = function() { 421 | var v = _ctrl.info('vertical') 422 | // apply stuff we didn't know before... 423 | _util.css(_elemStart.firstChild, { 424 | 'border-bottom-width': v ? 1 : 0, 425 | 'border-right-width': v ? 0 : 1, 426 | bottom: v ? -1 : options.indent, 427 | right: v ? options.indent : -1, 428 | padding: v ? '0 8px' : '2px 4px', 429 | }) 430 | _util.css(_elemEnd, { 431 | 'border-top-width': v ? 1 : 0, 432 | 'border-left-width': v ? 0 : 1, 433 | top: v ? '100%' : '', 434 | right: v ? options.indent : '', 435 | bottom: v ? '' : options.indent, 436 | left: v ? '' : '100%', 437 | padding: v ? '0 8px' : '2px 4px', 438 | }) 439 | // append 440 | _boundsContainer.appendChild(_elemBounds) 441 | } 442 | 443 | // remove bounds from list and DOM 444 | var removeBounds = function() { 445 | _elemBounds.parentNode.removeChild(_elemBounds) 446 | } 447 | 448 | // update the start and end positions of the scene 449 | var updateBounds = function() { 450 | if (_elemBounds.parentNode !== _boundsContainer) { 451 | addBounds() // Add Bounds elements (start/end) 452 | } 453 | var css = {} 454 | css[_vertical ? 'top' : 'left'] = Scene.triggerPosition() 455 | css[_vertical ? 'height' : 'width'] = Scene.duration() 456 | _util.css(_elemBounds, css) 457 | _util.css(_elemEnd, { 458 | display: Scene.duration() > 0 ? '' : 'none', 459 | }) 460 | } 461 | 462 | /* 463 | * ---------------------------------------------------------------- 464 | * trigger and trigger group management 465 | * ---------------------------------------------------------------- 466 | */ 467 | 468 | // adds an new trigger group to the array and to the DOM 469 | var addTriggerGroup = function() { 470 | var triggerElem = TPL.trigger(options.colorTrigger) // new trigger element 471 | var css = {} 472 | css[_vertical ? 'right' : 'bottom'] = 0 473 | css[_vertical ? 'border-top-width' : 'border-left-width'] = 1 474 | _util.css(triggerElem.firstChild, css) 475 | _util.css(triggerElem.firstChild.firstChild, { 476 | padding: _vertical ? '0 8px 3px 8px' : '3px 4px', 477 | }) 478 | document.body.appendChild(triggerElem) // directly add to body 479 | var newGroup = { 480 | triggerHook: Scene.triggerHook(), 481 | element: triggerElem, 482 | members: [Indicator], 483 | } 484 | _ctrl._indicators.groups.push(newGroup) 485 | Indicator.triggerGroup = newGroup 486 | // update right away 487 | _ctrl._indicators.updateTriggerGroupLabel(newGroup) 488 | _ctrl._indicators.updateTriggerGroupPositions(newGroup) 489 | } 490 | 491 | var removeTriggerGroup = function() { 492 | _ctrl._indicators.groups.splice(_ctrl._indicators.groups.indexOf(Indicator.triggerGroup), 1) 493 | Indicator.triggerGroup.element.parentNode.removeChild(Indicator.triggerGroup.element) 494 | Indicator.triggerGroup = undefined 495 | } 496 | 497 | // updates the trigger group -> either join existing or add new one 498 | /* 499 | * Logic: 500 | * 1 if a trigger group exist, check if it's in sync with Scene settings – if so, nothing else needs to happen 501 | * 2 try to find an existing one that matches Scene parameters 502 | * 2.1 If a match is found check if already assigned to an existing group 503 | * If so: 504 | * A: it was the last member of existing group -> kill whole group 505 | * B: the existing group has other members -> just remove from member list 506 | * 2.2 Assign to matching group 507 | * 3 if no new match could be found, check if assigned to existing group 508 | * A: yes, and it's the only member -> just update parameters and positions and keep using this group 509 | * B: yes but there are other members -> remove from member list and create a new one 510 | * C: no, so create a new one 511 | */ 512 | var updateTriggerGroup = function() { 513 | var triggerHook = Scene.triggerHook(), 514 | closeEnough = 0.0001 515 | 516 | // Have a group, check if it still matches 517 | if (Indicator.triggerGroup) { 518 | if (Math.abs(Indicator.triggerGroup.triggerHook - triggerHook) < closeEnough) { 519 | // _util.log(0, "trigger", options.name, "->", "no need to change, still in sync"); 520 | return // all good 521 | } 522 | } 523 | // Don't have a group, check if a matching one exists 524 | // _util.log(0, "trigger", options.name, "->", "out of sync!"); 525 | var groups = _ctrl._indicators.groups, 526 | group, 527 | i = groups.length 528 | while (i--) { 529 | group = groups[i] 530 | if (Math.abs(group.triggerHook - triggerHook) < closeEnough) { 531 | // found a match! 532 | // _util.log(0, "trigger", options.name, "->", "found match"); 533 | if (Indicator.triggerGroup) { 534 | // do I have an old group that is out of sync? 535 | if (Indicator.triggerGroup.members.length === 1) { 536 | // is it the only remaining group? 537 | // _util.log(0, "trigger", options.name, "->", "kill"); 538 | // was the last member, remove the whole group 539 | removeTriggerGroup() 540 | } else { 541 | Indicator.triggerGroup.members.splice(Indicator.triggerGroup.members.indexOf(Indicator), 1) // just remove from memberlist of old group 542 | _ctrl._indicators.updateTriggerGroupLabel(Indicator.triggerGroup) 543 | _ctrl._indicators.updateTriggerGroupPositions(Indicator.triggerGroup) 544 | // _util.log(0, "trigger", options.name, "->", "removing from previous member list"); 545 | } 546 | } 547 | // join new group 548 | group.members.push(Indicator) 549 | Indicator.triggerGroup = group 550 | _ctrl._indicators.updateTriggerGroupLabel(group) 551 | return 552 | } 553 | } 554 | 555 | // at this point I am obviously out of sync and don't match any other group 556 | if (Indicator.triggerGroup) { 557 | if (Indicator.triggerGroup.members.length === 1) { 558 | // _util.log(0, "trigger", options.name, "->", "updating existing"); 559 | // out of sync but i'm the only member => just change and update 560 | Indicator.triggerGroup.triggerHook = triggerHook 561 | _ctrl._indicators.updateTriggerGroupPositions(Indicator.triggerGroup) 562 | return 563 | } else { 564 | // _util.log(0, "trigger", options.name, "->", "removing from previous member list"); 565 | Indicator.triggerGroup.members.splice(Indicator.triggerGroup.members.indexOf(Indicator), 1) // just remove from memberlist of old group 566 | _ctrl._indicators.updateTriggerGroupLabel(Indicator.triggerGroup) 567 | _ctrl._indicators.updateTriggerGroupPositions(Indicator.triggerGroup) 568 | Indicator.triggerGroup = undefined // need a brand new group... 569 | } 570 | } 571 | // _util.log(0, "trigger", options.name, "->", "add a new one"); 572 | // did not find any match, make new trigger group 573 | addTriggerGroup() 574 | } 575 | } 576 | 577 | /* 578 | * ---------------------------------------------------------------- 579 | * Templates for the indicators 580 | * ---------------------------------------------------------------- 581 | */ 582 | var TPL = { 583 | start: function(color) { 584 | // inner element (for bottom offset -1, while keeping top position 0) 585 | var inner = document.createElement('div') 586 | inner.textContent = 'start' 587 | _util.css(inner, { 588 | position: 'absolute', 589 | overflow: 'visible', 590 | 'border-width': 0, 591 | 'border-style': 'solid', 592 | color: color, 593 | 'border-color': color, 594 | }) 595 | var e = document.createElement('div') 596 | // wrapper 597 | _util.css(e, { 598 | position: 'absolute', 599 | overflow: 'visible', 600 | width: 0, 601 | height: 0, 602 | }) 603 | e.appendChild(inner) 604 | return e 605 | }, 606 | end: function(color) { 607 | var e = document.createElement('div') 608 | e.textContent = 'end' 609 | _util.css(e, { 610 | position: 'absolute', 611 | overflow: 'visible', 612 | 'border-width': 0, 613 | 'border-style': 'solid', 614 | color: color, 615 | 'border-color': color, 616 | }) 617 | return e 618 | }, 619 | bounds: function() { 620 | var e = document.createElement('div') 621 | _util.css(e, { 622 | position: 'absolute', 623 | overflow: 'visible', 624 | 'white-space': 'nowrap', 625 | 'pointer-events': 'none', 626 | 'font-size': FONT_SIZE, 627 | }) 628 | e.style.zIndex = ZINDEX 629 | return e 630 | }, 631 | trigger: function(color) { 632 | // inner to be above or below line but keep position 633 | var inner = document.createElement('div') 634 | inner.textContent = 'trigger' 635 | _util.css(inner, { 636 | position: 'relative', 637 | }) 638 | // inner wrapper for right: 0 and main element has no size 639 | var w = document.createElement('div') 640 | _util.css(w, { 641 | position: 'absolute', 642 | overflow: 'visible', 643 | 'border-width': 0, 644 | 'border-style': 'solid', 645 | color: color, 646 | 'border-color': color, 647 | }) 648 | w.appendChild(inner) 649 | // wrapper 650 | var e = document.createElement('div') 651 | _util.css(e, { 652 | position: 'fixed', 653 | overflow: 'visible', 654 | 'white-space': 'nowrap', 655 | 'pointer-events': 'none', 656 | 'font-size': FONT_SIZE, 657 | }) 658 | e.style.zIndex = ZINDEX 659 | e.appendChild(w) 660 | return e 661 | }, 662 | } 663 | -------------------------------------------------------------------------------- /src/scrollmagic-add-indicators.ts: -------------------------------------------------------------------------------- 1 | var detect = require('is-client') 2 | var addindicators = undefined 3 | if (detect()) { 4 | addindicators = require('./plugins/debug.addIndicators') 5 | } 6 | 7 | export default addindicators 8 | -------------------------------------------------------------------------------- /src/scrollmagic-with-ssr.ts: -------------------------------------------------------------------------------- 1 | /* https://github.com/epranka/scrollmagic-with-ssr/blob/master/index.js */ 2 | var detect = require('is-client') 3 | var scrollmagic = undefined 4 | if (detect()) { 5 | scrollmagic = require('scrollmagic') 6 | } 7 | 8 | export default scrollmagic 9 | -------------------------------------------------------------------------------- /src/stories/Stylesheet.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | export const Stylesheet = () => ( 4 | 83 | ) 84 | -------------------------------------------------------------------------------- /src/stories/story.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { storiesOf } from '@storybook/react' 3 | import { ScrollScene } from '../ScrollScene' 4 | import { ScrollObserver } from '../ScrollObserver' 5 | import { gsap } from 'gsap' 6 | import { addIndicators } from '../index' 7 | import { Stylesheet } from './Stylesheet' 8 | 9 | import notes from '../../README.md' 10 | 11 | storiesOf('ScrollScene|toggle (className)', module) 12 | .add( 13 | 'triggerHook: 0', 14 | () => { 15 | // init ref 16 | const containerRef = React.useRef(null) 17 | const triggerRef = React.useRef(null) 18 | 19 | React.useEffect(() => { 20 | const { current: containerElement } = containerRef 21 | const { current: triggerElement } = triggerRef 22 | 23 | if (!containerElement && !triggerElement) { 24 | return undefined 25 | } 26 | 27 | const scrollScene = new ScrollScene({ 28 | triggerElement, 29 | toggle: { 30 | element: containerElement, 31 | className: 'turn-blue', 32 | }, 33 | triggerHook: 0, 34 | }) 35 | 36 | scrollScene.Scene.addIndicators({ name: 'pin scene', colorEnd: '#FFFFFF' }) 37 | 38 | return () => { 39 | scrollScene.destroy() 40 | } 41 | }) 42 | 43 | return ( 44 |
45 |
46 | 47 |

triggerHook: 0

48 |

Scroll Down

49 | 50 |
51 | 52 |
When this hits the top the page will turn blue
53 | 54 |
55 |
56 | ) 57 | }, 58 | { 59 | notes: { markdown: notes }, 60 | }, 61 | ) 62 | .add( 63 | 'triggerHook: 0.5', 64 | () => { 65 | // init ref 66 | const containerRef = React.useRef(null) 67 | const triggerRef = React.useRef(null) 68 | 69 | React.useEffect(() => { 70 | const { current: containerElement } = containerRef 71 | const { current: triggerElement } = triggerRef 72 | 73 | if (!containerElement && !triggerElement) { 74 | return undefined 75 | } 76 | 77 | const scrollScene = new ScrollScene({ 78 | triggerElement, 79 | toggle: { 80 | element: containerElement, 81 | className: 'turn-blue', 82 | }, 83 | triggerHook: 0.5, 84 | }) 85 | 86 | scrollScene.Scene.addIndicators({ name: 'pin scene', colorEnd: '#FFFFFF' }) 87 | 88 | return () => { 89 | scrollScene.destroy() 90 | } 91 | }) 92 | 93 | return ( 94 |
95 |
96 | 97 |

triggerHook: 0.5

98 |

Scroll Down

99 | 100 |
101 | 102 |
When this the middle of the page the page will turn blue
103 | 104 |
105 |
106 | ) 107 | }, 108 | { 109 | notes: { markdown: notes }, 110 | }, 111 | ) 112 | .add( 113 | 'triggerHook: 1', 114 | () => { 115 | // init ref 116 | const containerRef = React.useRef(null) 117 | const triggerRef = React.useRef(null) 118 | 119 | React.useEffect(() => { 120 | const { current: containerElement } = containerRef 121 | const { current: triggerElement } = triggerRef 122 | 123 | if (!containerElement && !triggerElement) { 124 | return undefined 125 | } 126 | 127 | const scrollScene = new ScrollScene({ 128 | triggerElement, 129 | toggle: { 130 | element: containerElement, 131 | className: 'turn-blue', 132 | }, 133 | triggerHook: 1, 134 | }) 135 | 136 | return () => { 137 | scrollScene.destroy() 138 | } 139 | }) 140 | 141 | return ( 142 |
143 |
144 | 145 |

triggerHook: 1

146 |

Scroll Down

147 | 148 |
149 | 150 |
151 | When this appears on page the page will turn blue 152 |
153 | 154 |
155 |
156 | ) 157 | }, 158 | { 159 | notes: { markdown: notes }, 160 | }, 161 | ) 162 | .add( 163 | 'duration: "100%", reverse: true', 164 | () => { 165 | // init ref 166 | const containerRef = React.useRef(null) 167 | const triggerRef = React.useRef(null) 168 | 169 | React.useEffect(() => { 170 | const { current: containerElement } = containerRef 171 | const { current: triggerElement } = triggerRef 172 | 173 | if (!containerElement && !triggerElement) { 174 | return undefined 175 | } 176 | 177 | const scrollScene = new ScrollScene({ 178 | triggerElement, 179 | toggle: { 180 | element: containerElement, 181 | className: 'turn-blue', 182 | reverse: true, 183 | }, 184 | triggerHook: 1, 185 | duration: '100%', 186 | }) 187 | 188 | return () => { 189 | scrollScene.destroy() 190 | } 191 | }) 192 | 193 | return ( 194 |
195 |
196 | 197 |

duration: "100%", reverse: true

198 |

Scroll Down

199 | 200 |
201 | 202 |
203 | When this hits the top of the page the page will turn blue and it will last for 100% of the viewport height 204 |
205 | 206 |
207 | 208 |
209 | When this hits the top of the page the page will reverse the scene 210 |
211 | 212 |
213 |
214 | ) 215 | }, 216 | { 217 | notes: { markdown: notes }, 218 | }, 219 | ) 220 | .add( 221 | 'offset: 100, triggerHook: 0.5', 222 | () => { 223 | // init ref 224 | const containerRef = React.useRef(null) 225 | const triggerRef = React.useRef(null) 226 | 227 | React.useEffect(() => { 228 | const { current: containerElement } = containerRef 229 | const { current: triggerElement } = triggerRef 230 | 231 | if (!containerElement && !triggerElement) { 232 | return undefined 233 | } 234 | 235 | const scrollScene = new ScrollScene({ 236 | controller: { 237 | loglevel: 3, 238 | }, 239 | triggerElement: triggerElement, 240 | toggle: { 241 | element: containerElement, 242 | className: 'turn-blue', 243 | }, 244 | offset: 100, 245 | triggerHook: 0.5, 246 | }) 247 | 248 | scrollScene.Scene.addIndicators({ name: 'pin scene', colorEnd: '#FFFFFF' }) 249 | 250 | return () => { 251 | scrollScene.destroy() 252 | } 253 | }) 254 | 255 | return ( 256 |
257 |
258 | 259 |

offset: 100, triggerHook: 0.5

260 |

Scroll Down

261 | 262 |
263 | 264 |
When this hits the middle of the page plus 100 pixels, the page with turn blue.
265 | 266 |
267 | 268 |
offset: 100, triggerHook: 0.5,
269 | 270 |
271 | 272 |
273 |
274 | ) 275 | }, 276 | { 277 | notes: { markdown: notes }, 278 | }, 279 | ) 280 | 281 | storiesOf('ScrollScene|events', module) 282 | .add( 283 | 'on enter, on leave', 284 | () => { 285 | // init ref 286 | const containerRef = React.useRef(null) 287 | const triggerRef = React.useRef(null) 288 | 289 | React.useEffect(() => { 290 | const { current: containerElement } = containerRef 291 | const { current: triggerElement } = triggerRef 292 | 293 | if (!containerElement && !triggerElement) { 294 | return undefined 295 | } 296 | 297 | const scrollScene = new ScrollScene({ 298 | triggerElement, 299 | toggle: { 300 | element: containerElement, 301 | className: 'turn-blue', 302 | reverse: true, 303 | }, 304 | triggerHook: 1, 305 | duration: '100%', 306 | }) 307 | 308 | scrollScene.Scene.on('enter', function(event) { 309 | console.log('Scene entered.') 310 | }) 311 | 312 | scrollScene.Scene.on('leave', function(event) { 313 | console.log('Scene left.') 314 | }) 315 | 316 | return () => { 317 | scrollScene.destroy() 318 | } 319 | }) 320 | 321 | return ( 322 |
323 |
324 | 325 |

{`scrollScene.Scene.on('enter', function(event) {console.log('Scene entered.')})`.toString()}

326 |

{`scrollScene.Scene.on('leave', function(event) {console.log('Scene left.')})`.toString()}

327 |

Scroll Down

328 | 329 |
330 | 331 |
When this hits the top of the page, console.log('Scene entered.')
332 | 333 |
334 | 335 |
336 | When this hits the top of the page, console.log("Scene left."); 337 |
338 | 339 |
340 |
341 | ) 342 | }, 343 | { 344 | notes: { markdown: notes }, 345 | }, 346 | ) 347 | .add( 348 | 'on progress', 349 | () => { 350 | // init ref 351 | const containerRef = React.useRef(null) 352 | const triggerRef = React.useRef(null) 353 | 354 | React.useEffect(() => { 355 | const { current: containerElement } = containerRef 356 | const { current: triggerElement } = triggerRef 357 | 358 | if (!containerElement && !triggerElement) { 359 | return undefined 360 | } 361 | 362 | const scrollScene = new ScrollScene({ 363 | triggerElement, 364 | toggle: { 365 | element: containerElement, 366 | className: 'turn-blue', 367 | reverse: true, 368 | }, 369 | triggerHook: 1, 370 | duration: '100%', 371 | }) 372 | 373 | scrollScene.Scene.on('progress', function(event) { 374 | console.log('Scene progress changed to ' + event.progress) 375 | }) 376 | 377 | return () => { 378 | scrollScene.destroy() 379 | } 380 | }) 381 | 382 | return ( 383 |
384 |
385 | 386 |

387 | {`scrollScene.Scene.on('progress', function(event) { 388 | console.log('Scene progress changed to ' + event.progress) 389 | })`.toString()} 390 |

391 |

Scroll Down

392 | 393 |
394 | 395 |
396 | When this hits the top of the page, console.log the progress duration the active scene 397 |
398 | 399 |
400 | 401 |
402 | When this hits the top of the page, the progress will stop console logging 403 |
404 | 405 |
406 |
407 | ) 408 | }, 409 | { 410 | notes: { markdown: notes }, 411 | }, 412 | ) 413 | 414 | storiesOf('ScrollScene|gsap', module) 415 | .add( 416 | 'Basic Example', 417 | () => { 418 | // init ref 419 | const containerRef = React.useRef(null) 420 | const triggerRef = React.useRef(null) 421 | const squareRef = React.useRef(null) 422 | 423 | React.useEffect(() => { 424 | const { current: containerElement } = containerRef 425 | const { current: triggerElement } = triggerRef 426 | const { current: squareElement } = squareRef 427 | 428 | if (!containerElement && !triggerElement && !squareElement) { 429 | return undefined 430 | } 431 | 432 | // create a timeline and add a tween 433 | const tl = gsap.timeline({ paused: true }) 434 | 435 | tl.to(squareElement, { 436 | x: -200, 437 | duration: 1, 438 | ease: 'power2.out', 439 | }) 440 | 441 | const scrollScene = new ScrollScene({ 442 | triggerElement, 443 | gsap: { 444 | timeline: tl, 445 | }, 446 | triggerHook: 0, 447 | }) 448 | 449 | return () => { 450 | scrollScene.destroy() 451 | } 452 | }) 453 | 454 | return ( 455 |
456 |
457 | 458 |

Basic Example

459 |

Scroll Down

460 | 461 |
462 | 463 |
464 | When this hits the top the page the container will move 20%, and scrolling back up will reverse it 465 | automatically 466 |
467 |
468 |
472 | 473 |
474 |
475 | ) 476 | }, 477 | { 478 | notes: { markdown: notes }, 479 | }, 480 | ) 481 | .add( 482 | 'reverseSpeed: 4, triggerHook: 0', 483 | () => { 484 | // init ref 485 | const containerRef = React.useRef(null) 486 | const triggerRef = React.useRef(null) 487 | const squareRef = React.useRef(null) 488 | 489 | React.useEffect(() => { 490 | const { current: containerElement } = containerRef 491 | const { current: triggerElement } = triggerRef 492 | const { current: squareElement } = squareRef 493 | 494 | if (!containerElement && !triggerElement && !squareElement) { 495 | return undefined 496 | } 497 | 498 | // create a timeline and add a tween 499 | const tl = gsap.timeline({ paused: true }) 500 | 501 | tl.to(squareElement, { 502 | x: -200, 503 | duration: 1, 504 | ease: 'power2.out', 505 | }) 506 | 507 | const scrollScene = new ScrollScene({ 508 | triggerElement, 509 | gsap: { 510 | timeline: tl, 511 | reverseSpeed: 4, 512 | }, 513 | triggerHook: 0, 514 | }) 515 | 516 | return () => { 517 | scrollScene.destroy() 518 | } 519 | }) 520 | 521 | return ( 522 |
523 |
524 | 525 |

reverseSpeed: 4, triggerHook: 0

526 |

Scroll Down

527 | 528 |
529 | 530 |
531 | When this hits the top the page the container will move 20%, and scrolling back up will reverse it 4 times 532 | faster 533 |
534 |
535 |
539 | 540 |
541 |
542 | ) 543 | }, 544 | { 545 | notes: { markdown: notes }, 546 | }, 547 | ) 548 | .add( 549 | 'forwardSpeed: 2, reverseSpeed: 0.5', 550 | () => { 551 | // init ref 552 | const containerRef = React.useRef(null) 553 | const triggerRef = React.useRef(null) 554 | const squareRef = React.useRef(null) 555 | 556 | React.useEffect(() => { 557 | const { current: containerElement } = containerRef 558 | const { current: triggerElement } = triggerRef 559 | const { current: squareElement } = squareRef 560 | 561 | if (!containerElement && !triggerElement && !squareElement) { 562 | return undefined 563 | } 564 | 565 | // create a timeline and add a tween 566 | const tl = gsap.timeline({ paused: true }) 567 | 568 | tl.to(squareElement, { 569 | x: -200, 570 | duration: 1, 571 | ease: 'power2.out', 572 | }) 573 | 574 | const scrollScene = new ScrollScene({ 575 | triggerElement, 576 | gsap: { 577 | timeline: tl, 578 | forwardSpeed: 2, 579 | reverseSpeed: 0.5, 580 | }, 581 | triggerHook: 0, 582 | }) 583 | 584 | return () => { 585 | scrollScene.destroy() 586 | } 587 | }) 588 | 589 | return ( 590 |
591 |
592 | 593 |

forwardSpeed: 2, reverseSpeed: 1

594 |

Scroll Down

595 | 596 |
597 | 598 |
599 | When this hits the top the page the container will move 20% and 2 times faster than normal, and scrolling 600 | back up will reverse it half it's normal speed 601 |
602 |
603 |
607 | 608 |
609 |
610 | ) 611 | }, 612 | { 613 | notes: { markdown: notes }, 614 | }, 615 | ) 616 | .add( 617 | 'duration: 500', 618 | () => { 619 | // init ref 620 | const containerRef = React.useRef(null) 621 | const triggerRef = React.useRef(null) 622 | const squareRef = React.useRef(null) 623 | 624 | React.useEffect(() => { 625 | const { current: containerElement } = containerRef 626 | const { current: triggerElement } = triggerRef 627 | const { current: squareElement } = squareRef 628 | 629 | if (!containerElement && !triggerElement && !squareElement) { 630 | return undefined 631 | } 632 | 633 | // create a timeline and add a tween 634 | const tl = gsap.timeline({ paused: true }) 635 | 636 | tl.to(squareElement, { 637 | x: -200, 638 | duration: 1, 639 | ease: 'none', 640 | }) 641 | 642 | const scrollScene = new ScrollScene({ 643 | triggerElement, 644 | gsap: { 645 | timeline: tl, 646 | }, 647 | duration: 500, 648 | triggerHook: 0, 649 | }) 650 | 651 | return () => { 652 | scrollScene.destroy() 653 | } 654 | }) 655 | 656 | return ( 657 |
658 |
659 | 660 |

duration: 500

661 |

Scroll Down

662 | 663 |
664 | 665 |
666 | When this hits the top the page the container will move 20% but attached to a 500px duration 667 |
668 |
669 |
673 | 674 |
675 |
676 | ) 677 | }, 678 | { 679 | notes: { markdown: notes }, 680 | }, 681 | ) 682 | 683 | storiesOf('ScrollScene|duration', module).add( 684 | 'duration: domNode', 685 | () => { 686 | // init ref 687 | const containerRef = React.useRef(null) 688 | const triggerRef = React.useRef(null) 689 | 690 | React.useEffect(() => { 691 | const { current: containerElement } = containerRef 692 | const { current: triggerElement } = triggerRef 693 | 694 | if (!containerElement && !triggerElement) { 695 | return undefined 696 | } 697 | 698 | const scrollScene = new ScrollScene({ 699 | triggerElement, 700 | toggle: { 701 | element: containerElement, 702 | className: 'turn-blue', 703 | reverse: true, 704 | }, 705 | triggerHook: 0.25, 706 | duration: triggerElement, 707 | }) 708 | 709 | scrollScene.Scene.addIndicators({ name: 'duration: domNode', colorEnd: '#FFFFFF' }) 710 | 711 | return () => { 712 | scrollScene.destroy() 713 | } 714 | }) 715 | 716 | return ( 717 |
718 |
719 | 720 |

triggerHook: 0

721 |

Scroll Down

722 | 723 |
724 | 725 |
726 | When this hits the top the page will turn blue 727 |
728 | 729 |
730 |
731 | ) 732 | }, 733 | { 734 | notes: { markdown: notes }, 735 | }, 736 | ) 737 | 738 | storiesOf('ScrollScene|controller', module).add( 739 | 'add options', 740 | () => { 741 | // init ref 742 | const containerRef = React.useRef(null) 743 | const triggerRef = React.useRef(null) 744 | 745 | React.useEffect(() => { 746 | const { current: containerElement } = containerRef 747 | const { current: triggerElement } = triggerRef 748 | 749 | if (!containerElement && !triggerElement) { 750 | return undefined 751 | } 752 | 753 | const scrollScene = new ScrollScene({ 754 | triggerElement, 755 | toggle: { 756 | element: containerElement, 757 | className: 'turn-blue', 758 | reverse: true, 759 | }, 760 | triggerHook: 1, 761 | duration: '100%', 762 | controller: { loglevel: 3 }, 763 | useGlobalController: false, 764 | }) 765 | 766 | console.log(scrollScene.Controller.info()) 767 | 768 | return () => { 769 | scrollScene.destroy() 770 | } 771 | }) 772 | 773 | return ( 774 |
775 |
776 | 777 |

{`controller: { loglevel: 3 } will log all the goods`.toString()}

778 |

Scroll and refresh page after leaving.

779 |

Otherwise you'll get annoyed with the amount of console.logging

780 | 781 |
782 | 783 |
Scroll and the console.logging will begin.
784 |
This page is using a local controller. Refresh page after leaving.
785 | 786 |
787 |
788 | ) 789 | }, 790 | { 791 | notes: { markdown: notes }, 792 | }, 793 | ) 794 | 795 | storiesOf('ScrollObserver|toggle (className)', module).add( 796 | 'Basic Example', 797 | () => { 798 | // init ref 799 | const containerRef = React.useRef(null) 800 | const triggerRef = React.useRef(null) 801 | 802 | React.useEffect(() => { 803 | const { current: containerElement } = containerRef 804 | const { current: triggerElement } = triggerRef 805 | 806 | if (!containerElement && !triggerElement) { 807 | return undefined 808 | } 809 | 810 | const scrollObserver = new ScrollObserver({ 811 | triggerElement, 812 | toggle: { 813 | element: containerElement, 814 | className: 'turn-blue', 815 | }, 816 | }) 817 | 818 | return () => { 819 | scrollObserver.destroy() 820 | } 821 | }) 822 | 823 | return ( 824 |
825 |
826 | 827 |

Basic Example

828 |

Scroll Down

829 | 830 |
831 | 832 |
833 | While this is visible on the page the page will turn blue 834 |
835 | 836 |
837 |
838 | ) 839 | }, 840 | { 841 | notes: { markdown: notes }, 842 | }, 843 | ) 844 | 845 | storiesOf('ScrollObserver|gsap', module) 846 | .add( 847 | 'Basic Example', 848 | () => { 849 | // init ref 850 | const containerRef = React.useRef(null) 851 | const triggerRef = React.useRef(null) 852 | const squareRef = React.useRef(null) 853 | 854 | React.useEffect(() => { 855 | const { current: containerElement } = containerRef 856 | const { current: triggerElement } = triggerRef 857 | const { current: squareElement } = squareRef 858 | 859 | if (!containerElement && !triggerElement && !squareElement) { 860 | return undefined 861 | } 862 | 863 | // create a timeline and add a tween 864 | const tl = gsap.timeline({ paused: true }) 865 | 866 | tl.to(squareElement, { 867 | x: -200, 868 | duration: 1, 869 | ease: 'power2.out', 870 | }) 871 | 872 | const scrollObserver = new ScrollObserver({ 873 | triggerElement, 874 | gsap: { 875 | timeline: tl, 876 | }, 877 | }) 878 | 879 | return () => { 880 | scrollObserver.destroy() 881 | } 882 | }) 883 | 884 | return ( 885 |
886 |
887 | 888 |

Basic Example

889 |

Scroll Down

890 | 891 |
892 | 893 |
While this is visible on the page the container will move 20%
894 |
895 |
899 | 900 |
901 |
902 | ) 903 | }, 904 | { 905 | notes: { markdown: notes }, 906 | }, 907 | ) 908 | .add( 909 | 'offset: "-10%"', 910 | () => { 911 | // init ref 912 | const containerRef = React.useRef(null) 913 | const triggerRef = React.useRef(null) 914 | const squareRef = React.useRef(null) 915 | 916 | React.useEffect(() => { 917 | const { current: containerElement } = containerRef 918 | const { current: triggerElement } = triggerRef 919 | const { current: squareElement } = squareRef 920 | 921 | if (!containerElement && !triggerElement && !squareElement) { 922 | return undefined 923 | } 924 | 925 | // create a timeline and add a tween 926 | const tl = gsap.timeline({ paused: true }) 927 | 928 | tl.to(squareElement, { 929 | x: -200, 930 | duration: 1, 931 | ease: 'power2.out', 932 | }) 933 | 934 | const scrollObserver = new ScrollObserver({ 935 | triggerElement, 936 | gsap: { 937 | timeline: tl, 938 | }, 939 | offset: '-10%', 940 | }) 941 | 942 | return () => { 943 | scrollObserver.destroy() 944 | } 945 | }) 946 | 947 | return ( 948 |
949 |
950 | 951 |

offset: '-10%',

952 |

Scroll Down

953 | 954 |
955 | 956 |
957 | While this container is visible on the page the container will move 20%, but using a -10% offset of the 958 | container 959 |
960 |
961 |
965 | 966 |
967 |
968 | ) 969 | }, 970 | { 971 | notes: { markdown: notes }, 972 | }, 973 | ) 974 | .add( 975 | 'yoyo: true, delay: 0', 976 | () => { 977 | // init ref 978 | const containerRef = React.useRef(null) 979 | const triggerRef = React.useRef(null) 980 | const squareRef = React.useRef(null) 981 | 982 | React.useEffect(() => { 983 | const { current: containerElement } = containerRef 984 | const { current: triggerElement } = triggerRef 985 | const { current: squareElement } = squareRef 986 | 987 | if (!containerElement && !triggerElement && !squareElement) { 988 | return undefined 989 | } 990 | 991 | // create a timeline and add a tween 992 | const tl = gsap.timeline({ paused: true }) 993 | 994 | tl.to(squareElement, { 995 | x: -200, 996 | duration: 1, 997 | ease: 'power2.out', 998 | }) 999 | 1000 | const scrollObserver = new ScrollObserver({ 1001 | triggerElement, 1002 | gsap: { 1003 | timeline: tl, 1004 | yoyo: true, 1005 | delay: 0, 1006 | }, 1007 | }) 1008 | 1009 | return () => { 1010 | scrollObserver.destroy() 1011 | } 1012 | }) 1013 | 1014 | return ( 1015 |
1016 |
1017 | 1018 |

yoyo: true, delay: 1

1019 |

Scroll Down

1020 | 1021 |
1022 | 1023 |
1024 | While this is visible on the page the container will move 20%, and the animation will yoyo with a delay of 0 1025 |
1026 |
1027 |
1031 | 1032 |
1033 |
1034 | ) 1035 | }, 1036 | { 1037 | notes: { markdown: notes }, 1038 | }, 1039 | ) 1040 | .add( 1041 | 'useDuration: true', 1042 | () => { 1043 | // init ref 1044 | const containerRef = React.useRef(null) 1045 | const triggerRef = React.useRef(null) 1046 | const squareRef = React.useRef(null) 1047 | 1048 | React.useEffect(() => { 1049 | const { current: containerElement } = containerRef 1050 | const { current: triggerElement } = triggerRef 1051 | const { current: squareElement } = squareRef 1052 | 1053 | if (!containerElement && !triggerElement && !squareElement) { 1054 | return undefined 1055 | } 1056 | 1057 | // create a timeline and add a tween 1058 | const tl = gsap.timeline({ paused: true }) 1059 | 1060 | tl.to(squareElement, { 1061 | x: -200, 1062 | duration: 1, 1063 | ease: 'none', 1064 | }) 1065 | 1066 | const scrollObserver = new ScrollObserver({ 1067 | triggerElement, 1068 | gsap: { 1069 | timeline: tl, 1070 | }, 1071 | useDuration: true, 1072 | thresholds: 100, 1073 | }) 1074 | 1075 | return () => { 1076 | scrollObserver.destroy() 1077 | } 1078 | }) 1079 | 1080 | return ( 1081 |
1082 |
1083 | 1084 |

useDuration: true

1085 |

Scroll Down

1086 | 1087 |
1088 | 1089 |
1090 | While this container is visible on the page the container will move 20%, but tied to the visibility of the 1091 | container 1092 |
1093 |
1094 |
1098 | 1099 |
1100 |
1101 | ) 1102 | }, 1103 | { 1104 | notes: { markdown: notes }, 1105 | }, 1106 | ) 1107 | 1108 | storiesOf('ScrollObserver|video', module) 1109 | .add( 1110 | 'whenVisible: "50%"', 1111 | () => { 1112 | // init ref 1113 | const videoRef = React.useRef(null) 1114 | 1115 | React.useEffect(() => { 1116 | const { current: videoElement } = videoRef 1117 | 1118 | if (!videoElement) { 1119 | return undefined 1120 | } 1121 | 1122 | const scrollObserver = new ScrollObserver({ 1123 | triggerElement: videoElement, 1124 | video: { 1125 | element: videoElement, 1126 | playingClassName: 'is-playing', 1127 | pausedClassName: 'is-paused', 1128 | }, 1129 | whenVisible: '50%', 1130 | }) 1131 | 1132 | return () => { 1133 | scrollObserver.destroy() 1134 | } 1135 | }) 1136 | 1137 | return ( 1138 |
1139 |
1140 | 1141 |

whenVisible: '50%'

1142 |

Scroll Down

1143 | 1144 |
1145 | 1146 |
While the video is 50% visible on the page the video will play, and pause when not 50%
1147 |
1148 |