├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── dist ├── vue-scrollama.esm.js ├── vue-scrollama.min.js └── vue-scrollama.umd.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── rollup.config.js └── src ├── Scrollama.vue ├── index.js └── wrapper.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | 4 | # local env files 5 | .env.local 6 | .env.*.local 7 | 8 | # Log files 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | 13 | # Editor directories and files 14 | .idea 15 | .vscode 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw* 21 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | build/ 2 | examples/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Vignesh Shenoy 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 | # Vue Scrollama 2 | 3 |

4 | 5 | Vue logo 6 | 7 | 8 | scrollama.js 9 | 10 |

11 | 12 | A Vue component to easily setup scroll-driven interactions (aka scrollytelling). Uses [Scrollama](https://github.com/russellgoldenberg/scrollama) under the hood. 13 | 14 | The best way to understand what it can do for you is to check out the examples [here](https://vue-scrollama.vercel.app) and [here](#examples). 15 | 16 | If you're upgrading from v1 to v2 (which you should), do check out the [release notes](#release-notes). 17 | 18 | ## Installation 19 | 20 | ```sh 21 | npm install vue-scrollama intersection-observer 22 | ``` 23 | Scrollama makes use of [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) and you'll want to manually add its polyfill `intersection-observer` for cross-browser support. 24 | 25 | ## Basic Usage 26 | 27 | Any elements placed directly inside a `Scrollama` component will be considered as steps. As the user scrolls, events will be triggered and emitted which you can handle as required: 28 | 29 | * `step-enter`: when the top or bottom edge of a step element enters the offset threshold 30 | * `step-exit`: when the top or bottom edge of a step element exits the offset threshold 31 | * `step-progress`: continually fires the progress (0-1) a step has made through the threshold 32 | 33 | Here's a simple example with three `
` elements as steps and a `step-enter` event 34 | 35 | ```vue 36 | 43 | 44 | 62 | ``` 63 | 64 | ## API Reference 65 | 66 | ### Props 67 | Props passed to the `Scrollama` component will simply be passed on to scrollama's [setup method](https://github.com/russellgoldenberg/scrollama#scrollamasetupoptions): 68 | 69 | ```vue 70 | // example with offset prop set to 0.8 71 | 76 | ``` 77 | 78 | ### Events 79 | * `step-enter` 80 | * `step-exit` 81 | * `step-progress` 82 | 83 | 84 | ## Examples 85 | 86 | ### Codesandbox 87 | 88 | Note: The triggering might not work precisely in the split window browser in CodeSandbox. Open in a new window for more precise triggering. 89 | 90 | * [Basic](https://codesandbox.io/s/5kn98j4w74) 91 | * [Progress](https://codesandbox.io/s/ryx25zrj5q) 92 | * [Sticky Graphic 1](https://codesandbox.io/s/j3oy2k6lxv) 93 | * [Sticky Graphic 2](https://codesandbox.io/s/jznvyjpr9w) 94 | 95 | and [more](https://codesandbox.io/search?query=vue-scrollama%20vgshenoy&page=1&refinementList%5Bnpm_dependencies.dependency%5D%5B0%5D=vue-scrollama). 96 | 97 | ### Nuxt 98 | 99 | Example repo [here](https://github.com/vgshenoy/vue-scrollama-demo-nuxt). 100 | 101 | ## Release Notes 102 | 103 | ### v2.0 104 | 105 | * Fixes buggy behaviour and improves performance on mobile devices 106 | * Updated in accordance with the latest `scrollama` API 107 | * *Breaking*: No more `graphic` slot, create your graphic outside the `Scrollama` component now and style it as per your needs (have a look at the examples above for guidance) 108 | * DOM scaffolding generated by `Scrollama` has been simplified 109 | * No need to import CSS any more, the DOM scaffolding is just one `div` and can be styled by adding classes or styles on the `Scrollama` component 110 | -------------------------------------------------------------------------------- /dist/vue-scrollama.esm.js: -------------------------------------------------------------------------------- 1 | // DOM helper functions 2 | 3 | // public 4 | function selectAll(selector, parent = document) { 5 | if (typeof selector === 'string') { 6 | return Array.from(parent.querySelectorAll(selector)); 7 | } else if (selector instanceof Element) { 8 | return [selector]; 9 | } else if (selector instanceof NodeList) { 10 | return Array.from(selector); 11 | } else if (selector instanceof Array) { 12 | return selector; 13 | } 14 | return []; 15 | } 16 | 17 | function getOffsetId(id) { 18 | return `scrollama__debug-offset--${id}`; 19 | } 20 | 21 | // SETUP 22 | function setupOffset({ id, offsetVal, stepClass }) { 23 | const el = document.createElement("div"); 24 | el.id = getOffsetId(id); 25 | el.className = "scrollama__debug-offset"; 26 | el.style.position = "fixed"; 27 | el.style.left = "0"; 28 | el.style.width = "100%"; 29 | el.style.height = "0"; 30 | el.style.borderTop = "2px dashed black"; 31 | el.style.zIndex = "9999"; 32 | 33 | const p = document.createElement("p"); 34 | p.innerHTML = `".${stepClass}" trigger: ${offsetVal}`; 35 | p.style.fontSize = "12px"; 36 | p.style.fontFamily = "monospace"; 37 | p.style.color = "black"; 38 | p.style.margin = "0"; 39 | p.style.padding = "6px"; 40 | el.appendChild(p); 41 | document.body.appendChild(el); 42 | } 43 | 44 | function setup({ id, offsetVal, stepEl }) { 45 | const stepClass = stepEl[0].className; 46 | setupOffset({ id, offsetVal, stepClass }); 47 | } 48 | 49 | // UPDATE 50 | function update({ id, offsetMargin, offsetVal, format }) { 51 | const post = format === "pixels" ? "px" : ""; 52 | const idVal = getOffsetId(id); 53 | const el = document.getElementById(idVal); 54 | el.style.top = `${offsetMargin}px`; 55 | el.querySelector("span").innerText = `${offsetVal}${post}`; 56 | } 57 | 58 | function notifyStep({ id, index, state }) { 59 | const prefix = `scrollama__debug-step--${id}-${index}`; 60 | const elA = document.getElementById(`${prefix}_above`); 61 | const elB = document.getElementById(`${prefix}_below`); 62 | const display = state === "enter" ? "block" : "none"; 63 | 64 | if (elA) elA.style.display = display; 65 | if (elB) elB.style.display = display; 66 | } 67 | 68 | function scrollama() { 69 | const OBSERVER_NAMES = [ 70 | "stepAbove", 71 | "stepBelow", 72 | "stepProgress", 73 | "viewportAbove", 74 | "viewportBelow" 75 | ]; 76 | 77 | let cb = {}; 78 | let io = {}; 79 | 80 | let id = null; 81 | let stepEl = []; 82 | let stepOffsetHeight = []; 83 | let stepOffsetTop = []; 84 | let stepStates = []; 85 | 86 | let offsetVal = 0; 87 | let offsetMargin = 0; 88 | let viewH = 0; 89 | let pageH = 0; 90 | let previousYOffset = 0; 91 | let progressThreshold = 0; 92 | 93 | let isReady = false; 94 | let isEnabled = false; 95 | let isDebug = false; 96 | 97 | let progressMode = false; 98 | let preserveOrder = false; 99 | let triggerOnce = false; 100 | 101 | let direction = "down"; 102 | let format = "percent"; 103 | 104 | const exclude = []; 105 | 106 | /* HELPERS */ 107 | function err(msg) { 108 | console.error(`scrollama error: ${msg}`); 109 | } 110 | 111 | function reset() { 112 | cb = { 113 | stepEnter: () => {}, 114 | stepExit: () => {}, 115 | stepProgress: () => {} 116 | }; 117 | io = {}; 118 | } 119 | 120 | function generateInstanceID() { 121 | const a = "abcdefghijklmnopqrstuv"; 122 | const l = a.length; 123 | const t = Date.now(); 124 | const r = [0, 0, 0].map(d => a[Math.floor(Math.random() * l)]).join(""); 125 | return `${r}${t}`; 126 | } 127 | 128 | function getOffsetTop(el) { 129 | const { top } = el.getBoundingClientRect(); 130 | const scrollTop = window.pageYOffset; 131 | const clientTop = document.body.clientTop || 0; 132 | return top + scrollTop - clientTop; 133 | } 134 | 135 | function getPageHeight() { 136 | const { body } = document; 137 | const html = document.documentElement; 138 | 139 | return Math.max( 140 | body.scrollHeight, 141 | body.offsetHeight, 142 | html.clientHeight, 143 | html.scrollHeight, 144 | html.offsetHeight 145 | ); 146 | } 147 | 148 | function getIndex(element) { 149 | return +element.getAttribute("data-scrollama-index"); 150 | } 151 | 152 | function updateDirection() { 153 | if (window.pageYOffset > previousYOffset) direction = "down"; 154 | else if (window.pageYOffset < previousYOffset) direction = "up"; 155 | previousYOffset = window.pageYOffset; 156 | } 157 | 158 | function disconnectObserver(name) { 159 | if (io[name]) io[name].forEach(d => d.disconnect()); 160 | } 161 | 162 | function handleResize() { 163 | viewH = window.innerHeight; 164 | pageH = getPageHeight(); 165 | 166 | const mult = format === "pixels" ? 1 : viewH; 167 | offsetMargin = offsetVal * mult; 168 | 169 | if (isReady) { 170 | stepOffsetHeight = stepEl.map(el => el.getBoundingClientRect().height); 171 | stepOffsetTop = stepEl.map(getOffsetTop); 172 | if (isEnabled) updateIO(); 173 | } 174 | 175 | if (isDebug) update({ id, offsetMargin, offsetVal, format }); 176 | } 177 | 178 | function handleEnable(enable) { 179 | if (enable && !isEnabled) { 180 | // enable a disabled scroller 181 | if (isReady) { 182 | // enable a ready scroller 183 | updateIO(); 184 | } else { 185 | // can't enable an unready scroller 186 | err("scrollama error: enable() called before scroller was ready"); 187 | isEnabled = false; 188 | return; // all is not well, don't set the requested state 189 | } 190 | } 191 | if (!enable && isEnabled) { 192 | // disable an enabled scroller 193 | OBSERVER_NAMES.forEach(disconnectObserver); 194 | } 195 | isEnabled = enable; // all is well, set requested state 196 | } 197 | 198 | function createThreshold(height) { 199 | const count = Math.ceil(height / progressThreshold); 200 | const t = []; 201 | const ratio = 1 / count; 202 | for (let i = 0; i < count; i += 1) { 203 | t.push(i * ratio); 204 | } 205 | return t; 206 | } 207 | 208 | /* NOTIFY CALLBACKS */ 209 | function notifyStepProgress(element, progress) { 210 | const index = getIndex(element); 211 | if (progress !== undefined) stepStates[index].progress = progress; 212 | const resp = { element, index, progress: stepStates[index].progress }; 213 | 214 | if (stepStates[index].state === "enter") cb.stepProgress(resp); 215 | } 216 | 217 | function notifyOthers(index, location) { 218 | if (location === "above") { 219 | // check if steps above/below were skipped and should be notified first 220 | for (let i = 0; i < index; i += 1) { 221 | const ss = stepStates[i]; 222 | if (ss.state !== "enter" && ss.direction !== "down") { 223 | notifyStepEnter(stepEl[i], "down", false); 224 | notifyStepExit(stepEl[i], "down"); 225 | } else if (ss.state === "enter") notifyStepExit(stepEl[i], "down"); 226 | // else if (ss.direction === 'up') { 227 | // notifyStepEnter(stepEl[i], 'down', false); 228 | // notifyStepExit(stepEl[i], 'down'); 229 | // } 230 | } 231 | } else if (location === "below") { 232 | for (let i = stepStates.length - 1; i > index; i -= 1) { 233 | const ss = stepStates[i]; 234 | if (ss.state === "enter") { 235 | notifyStepExit(stepEl[i], "up"); 236 | } 237 | if (ss.direction === "down") { 238 | notifyStepEnter(stepEl[i], "up", false); 239 | notifyStepExit(stepEl[i], "up"); 240 | } 241 | } 242 | } 243 | } 244 | 245 | function notifyStepEnter(element, dir, check = true) { 246 | const index = getIndex(element); 247 | const resp = { element, index, direction: dir }; 248 | 249 | // store most recent trigger 250 | stepStates[index].direction = dir; 251 | stepStates[index].state = "enter"; 252 | if (preserveOrder && check && dir === "down") notifyOthers(index, "above"); 253 | 254 | if (preserveOrder && check && dir === "up") notifyOthers(index, "below"); 255 | 256 | if (cb.stepEnter && !exclude[index]) { 257 | cb.stepEnter(resp, stepStates); 258 | if (isDebug) notifyStep({ id, index, state: "enter" }); 259 | if (triggerOnce) exclude[index] = true; 260 | } 261 | 262 | if (progressMode) notifyStepProgress(element); 263 | } 264 | 265 | function notifyStepExit(element, dir) { 266 | const index = getIndex(element); 267 | const resp = { element, index, direction: dir }; 268 | 269 | if (progressMode) { 270 | if (dir === "down" && stepStates[index].progress < 1) 271 | notifyStepProgress(element, 1); 272 | else if (dir === "up" && stepStates[index].progress > 0) 273 | notifyStepProgress(element, 0); 274 | } 275 | 276 | // store most recent trigger 277 | stepStates[index].direction = dir; 278 | stepStates[index].state = "exit"; 279 | 280 | cb.stepExit(resp, stepStates); 281 | if (isDebug) notifyStep({ id, index, state: "exit" }); 282 | } 283 | 284 | /* OBSERVER - INTERSECT HANDLING */ 285 | // this is good for entering while scrolling down + leaving while scrolling up 286 | function intersectStepAbove([entry]) { 287 | updateDirection(); 288 | const { isIntersecting, boundingClientRect, target } = entry; 289 | 290 | // bottom = bottom edge of element from top of viewport 291 | // bottomAdjusted = bottom edge of element from trigger 292 | const { top, bottom } = boundingClientRect; 293 | const topAdjusted = top - offsetMargin; 294 | const bottomAdjusted = bottom - offsetMargin; 295 | const index = getIndex(target); 296 | const ss = stepStates[index]; 297 | 298 | // entering above is only when topAdjusted is negative 299 | // and bottomAdjusted is positive 300 | if ( 301 | isIntersecting && 302 | topAdjusted <= 0 && 303 | bottomAdjusted >= 0 && 304 | direction === "down" && 305 | ss.state !== "enter" 306 | ) 307 | notifyStepEnter(target, direction); 308 | 309 | // exiting from above is when topAdjusted is positive and not intersecting 310 | if ( 311 | !isIntersecting && 312 | topAdjusted > 0 && 313 | direction === "up" && 314 | ss.state === "enter" 315 | ) 316 | notifyStepExit(target, direction); 317 | } 318 | 319 | // this is good for entering while scrolling up + leaving while scrolling down 320 | function intersectStepBelow([entry]) { 321 | updateDirection(); 322 | const { isIntersecting, boundingClientRect, target } = entry; 323 | 324 | // bottom = bottom edge of element from top of viewport 325 | // bottomAdjusted = bottom edge of element from trigger 326 | const { top, bottom } = boundingClientRect; 327 | const topAdjusted = top - offsetMargin; 328 | const bottomAdjusted = bottom - offsetMargin; 329 | const index = getIndex(target); 330 | const ss = stepStates[index]; 331 | 332 | // entering below is only when bottomAdjusted is positive 333 | // and topAdjusted is negative 334 | if ( 335 | isIntersecting && 336 | topAdjusted <= 0 && 337 | bottomAdjusted >= 0 && 338 | direction === "up" && 339 | ss.state !== "enter" 340 | ) 341 | notifyStepEnter(target, direction); 342 | 343 | // exiting from above is when bottomAdjusted is negative and not intersecting 344 | if ( 345 | !isIntersecting && 346 | bottomAdjusted < 0 && 347 | direction === "down" && 348 | ss.state === "enter" 349 | ) 350 | notifyStepExit(target, direction); 351 | } 352 | 353 | /* 354 | if there is a scroll event where a step never intersects (therefore 355 | skipping an enter/exit trigger), use this fallback to detect if it is 356 | in view 357 | */ 358 | function intersectViewportAbove([entry]) { 359 | updateDirection(); 360 | const { isIntersecting, target } = entry; 361 | const index = getIndex(target); 362 | const ss = stepStates[index]; 363 | 364 | if ( 365 | isIntersecting && 366 | direction === "down" && 367 | ss.direction !== "down" && 368 | ss.state !== "enter" 369 | ) { 370 | notifyStepEnter(target, "down"); 371 | notifyStepExit(target, "down"); 372 | } 373 | } 374 | 375 | function intersectViewportBelow([entry]) { 376 | updateDirection(); 377 | const { isIntersecting, target } = entry; 378 | const index = getIndex(target); 379 | const ss = stepStates[index]; 380 | if ( 381 | isIntersecting && 382 | direction === "up" && 383 | ss.direction === "down" && 384 | ss.state !== "enter" 385 | ) { 386 | notifyStepEnter(target, "up"); 387 | notifyStepExit(target, "up"); 388 | } 389 | } 390 | 391 | function intersectStepProgress([entry]) { 392 | updateDirection(); 393 | const { 394 | isIntersecting, 395 | intersectionRatio, 396 | boundingClientRect, 397 | target 398 | } = entry; 399 | const { bottom } = boundingClientRect; 400 | const bottomAdjusted = bottom - offsetMargin; 401 | if (isIntersecting && bottomAdjusted >= 0) { 402 | notifyStepProgress(target, +intersectionRatio); 403 | } 404 | } 405 | 406 | /* OBSERVER - CREATION */ 407 | // jump into viewport 408 | function updateViewportAboveIO() { 409 | io.viewportAbove = stepEl.map((el, i) => { 410 | const marginTop = pageH - stepOffsetTop[i]; 411 | const marginBottom = offsetMargin - viewH - stepOffsetHeight[i]; 412 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 413 | const options = { rootMargin }; 414 | // console.log(options); 415 | const obs = new IntersectionObserver(intersectViewportAbove, options); 416 | obs.observe(el); 417 | return obs; 418 | }); 419 | } 420 | 421 | function updateViewportBelowIO() { 422 | io.viewportBelow = stepEl.map((el, i) => { 423 | const marginTop = -offsetMargin - stepOffsetHeight[i]; 424 | const marginBottom = offsetMargin - viewH + stepOffsetHeight[i] + pageH; 425 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 426 | const options = { rootMargin }; 427 | // console.log(options); 428 | const obs = new IntersectionObserver(intersectViewportBelow, options); 429 | obs.observe(el); 430 | return obs; 431 | }); 432 | } 433 | 434 | // look above for intersection 435 | function updateStepAboveIO() { 436 | io.stepAbove = stepEl.map((el, i) => { 437 | const marginTop = -offsetMargin + stepOffsetHeight[i]; 438 | const marginBottom = offsetMargin - viewH; 439 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 440 | const options = { rootMargin }; 441 | // console.log(options); 442 | const obs = new IntersectionObserver(intersectStepAbove, options); 443 | obs.observe(el); 444 | return obs; 445 | }); 446 | } 447 | 448 | // look below for intersection 449 | function updateStepBelowIO() { 450 | io.stepBelow = stepEl.map((el, i) => { 451 | const marginTop = -offsetMargin; 452 | const marginBottom = offsetMargin - viewH + stepOffsetHeight[i]; 453 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 454 | const options = { rootMargin }; 455 | // console.log(options); 456 | const obs = new IntersectionObserver(intersectStepBelow, options); 457 | obs.observe(el); 458 | return obs; 459 | }); 460 | } 461 | 462 | // progress progress tracker 463 | function updateStepProgressIO() { 464 | io.stepProgress = stepEl.map((el, i) => { 465 | const marginTop = stepOffsetHeight[i] - offsetMargin; 466 | const marginBottom = -viewH + offsetMargin; 467 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 468 | const threshold = createThreshold(stepOffsetHeight[i]); 469 | const options = { rootMargin, threshold }; 470 | // console.log(options); 471 | const obs = new IntersectionObserver(intersectStepProgress, options); 472 | obs.observe(el); 473 | return obs; 474 | }); 475 | } 476 | 477 | function updateIO() { 478 | OBSERVER_NAMES.forEach(disconnectObserver); 479 | 480 | updateViewportAboveIO(); 481 | updateViewportBelowIO(); 482 | updateStepAboveIO(); 483 | updateStepBelowIO(); 484 | 485 | if (progressMode) updateStepProgressIO(); 486 | } 487 | 488 | /* SETUP FUNCTIONS */ 489 | 490 | function indexSteps() { 491 | stepEl.forEach((el, i) => el.setAttribute("data-scrollama-index", i)); 492 | } 493 | 494 | function setupStates() { 495 | stepStates = stepEl.map(() => ({ 496 | direction: null, 497 | state: null, 498 | progress: 0 499 | })); 500 | } 501 | 502 | function addDebug() { 503 | if (isDebug) setup({ id, stepEl, offsetVal }); 504 | } 505 | 506 | function isYScrollable(element) { 507 | const style = window.getComputedStyle(element); 508 | return ( 509 | (style.overflowY === "scroll" || style.overflowY === "auto") && 510 | element.scrollHeight > element.clientHeight 511 | ); 512 | } 513 | 514 | // recursively search the DOM for a parent container with overflowY: scroll and fixed height 515 | // ends at document 516 | function anyScrollableParent(element) { 517 | if (element && element.nodeType === 1) { 518 | // check dom elements only, stop at document 519 | // if a scrollable element is found return the element 520 | // if not continue to next parent 521 | return isYScrollable(element) 522 | ? element 523 | : anyScrollableParent(element.parentNode); 524 | } 525 | return false; // didn't find a scrollable parent 526 | } 527 | 528 | const S = {}; 529 | 530 | S.setup = ({ 531 | step, 532 | parent, 533 | offset = 0.5, 534 | progress = false, 535 | threshold = 4, 536 | debug = false, 537 | order = true, 538 | once = false 539 | }) => { 540 | reset(); 541 | // create id unique to this scrollama instance 542 | id = generateInstanceID(); 543 | 544 | stepEl = selectAll(step, parent); 545 | 546 | if (!stepEl.length) { 547 | err("no step elements"); 548 | return S; 549 | } 550 | 551 | // ensure that no step has a scrollable parent element in the dom tree 552 | // check current step for scrollable parent 553 | // assume no scrollable parents to start 554 | const scrollableParent = stepEl.reduce( 555 | (foundScrollable, s) => 556 | foundScrollable || anyScrollableParent(s.parentNode), 557 | false 558 | ); 559 | if (scrollableParent) { 560 | console.error( 561 | "scrollama error: step elements cannot be children of a scrollable element. Remove any css on the parent element with overflow: scroll; or overflow: auto; on elements with fixed height.", 562 | scrollableParent 563 | ); 564 | } 565 | 566 | // options 567 | isDebug = debug; 568 | progressMode = progress; 569 | preserveOrder = order; 570 | triggerOnce = once; 571 | 572 | S.offsetTrigger(offset); 573 | progressThreshold = Math.max(1, +threshold); 574 | 575 | isReady = true; 576 | 577 | // customize 578 | addDebug(); 579 | indexSteps(); 580 | setupStates(); 581 | handleResize(); 582 | S.enable(); 583 | return S; 584 | }; 585 | 586 | S.resize = () => { 587 | handleResize(); 588 | return S; 589 | }; 590 | 591 | S.enable = () => { 592 | handleEnable(true); 593 | return S; 594 | }; 595 | 596 | S.disable = () => { 597 | handleEnable(false); 598 | return S; 599 | }; 600 | 601 | S.destroy = () => { 602 | handleEnable(false); 603 | reset(); 604 | }; 605 | 606 | S.offsetTrigger = x => { 607 | if (x === null) return offsetVal; 608 | 609 | if (typeof x === "number") { 610 | format = "percent"; 611 | if (x > 1) err("offset value is greater than 1. Fallback to 1."); 612 | if (x < 0) err("offset value is lower than 0. Fallback to 0."); 613 | offsetVal = Math.min(Math.max(0, x), 1); 614 | } else if (typeof x === "string" && x.indexOf("px") > 0) { 615 | const v = +x.replace("px", ""); 616 | if (!isNaN(v)) { 617 | format = "pixels"; 618 | offsetVal = v; 619 | } else { 620 | err("offset value must be in 'px' format. Fallback to 0.5."); 621 | offsetVal = 0.5; 622 | } 623 | } else { 624 | err("offset value does not include 'px'. Fallback to 0.5."); 625 | offsetVal = 0.5; 626 | } 627 | return S; 628 | }; 629 | 630 | S.onStepEnter = f => { 631 | if (typeof f === "function") cb.stepEnter = f; 632 | else err("onStepEnter requires a function"); 633 | return S; 634 | }; 635 | 636 | S.onStepExit = f => { 637 | if (typeof f === "function") cb.stepExit = f; 638 | else err("onStepExit requires a function"); 639 | return S; 640 | }; 641 | 642 | S.onStepProgress = f => { 643 | if (typeof f === "function") cb.stepProgress = f; 644 | else err("onStepProgress requires a function"); 645 | return S; 646 | }; 647 | 648 | return S; 649 | } 650 | 651 | // 652 | 653 | var script = { 654 | inheritAttrs: false, 655 | name: 'Scrollama', 656 | mounted () { 657 | this._scroller = scrollama(); 658 | this.setup(); 659 | }, 660 | beforeDestroy() { 661 | this._scroller.destroy(); 662 | }, 663 | computed: { 664 | opts() { 665 | return Object.assign({}, { 666 | step: Array.from(this.$el.children), 667 | progress: !!this.$listeners['step-progress'] 668 | }, this.$attrs); 669 | } 670 | }, 671 | methods: { 672 | setup() { 673 | this._scroller.destroy(); 674 | 675 | this._scroller 676 | .setup(this.opts) 677 | .onStepProgress(resp => { 678 | this.$emit('step-progress', resp); 679 | }) 680 | .onStepEnter(resp => { 681 | this.$emit('step-enter', resp); 682 | }) 683 | .onStepExit(resp => { 684 | this.$emit('step-exit', resp); 685 | }); 686 | 687 | window.addEventListener('resize', this.handleResize); 688 | }, 689 | handleResize () { 690 | this._scroller.resize(); 691 | } 692 | } 693 | }; 694 | 695 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier /* server only */, shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 696 | if (typeof shadowMode !== 'boolean') { 697 | createInjectorSSR = createInjector; 698 | createInjector = shadowMode; 699 | shadowMode = false; 700 | } 701 | // Vue.extend constructor export interop. 702 | const options = typeof script === 'function' ? script.options : script; 703 | // render functions 704 | if (template && template.render) { 705 | options.render = template.render; 706 | options.staticRenderFns = template.staticRenderFns; 707 | options._compiled = true; 708 | // functional template 709 | if (isFunctionalTemplate) { 710 | options.functional = true; 711 | } 712 | } 713 | // scopedId 714 | if (scopeId) { 715 | options._scopeId = scopeId; 716 | } 717 | let hook; 718 | if (moduleIdentifier) { 719 | // server build 720 | hook = function (context) { 721 | // 2.3 injection 722 | context = 723 | context || // cached call 724 | (this.$vnode && this.$vnode.ssrContext) || // stateful 725 | (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext); // functional 726 | // 2.2 with runInNewContext: true 727 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 728 | context = __VUE_SSR_CONTEXT__; 729 | } 730 | // inject component styles 731 | if (style) { 732 | style.call(this, createInjectorSSR(context)); 733 | } 734 | // register component module identifier for async chunk inference 735 | if (context && context._registeredComponents) { 736 | context._registeredComponents.add(moduleIdentifier); 737 | } 738 | }; 739 | // used by ssr in case component is cached and beforeCreate 740 | // never gets called 741 | options._ssrRegister = hook; 742 | } 743 | else if (style) { 744 | hook = shadowMode 745 | ? function (context) { 746 | style.call(this, createInjectorShadow(context, this.$root.$options.shadowRoot)); 747 | } 748 | : function (context) { 749 | style.call(this, createInjector(context)); 750 | }; 751 | } 752 | if (hook) { 753 | if (options.functional) { 754 | // register for functional component in vue file 755 | const originalRender = options.render; 756 | options.render = function renderWithStyleInjection(h, context) { 757 | hook.call(context); 758 | return originalRender(h, context); 759 | }; 760 | } 761 | else { 762 | // inject component registration as beforeCreate hook 763 | const existing = options.beforeCreate; 764 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 765 | } 766 | } 767 | return script; 768 | } 769 | 770 | /* script */ 771 | const __vue_script__ = script; 772 | 773 | /* template */ 774 | var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"scrollama__steps"},[_vm._t("default")],2)}; 775 | var __vue_staticRenderFns__ = []; 776 | 777 | /* style */ 778 | const __vue_inject_styles__ = undefined; 779 | /* scoped */ 780 | const __vue_scope_id__ = undefined; 781 | /* module identifier */ 782 | const __vue_module_identifier__ = undefined; 783 | /* functional template */ 784 | const __vue_is_functional_template__ = false; 785 | /* style inject */ 786 | 787 | /* style inject SSR */ 788 | 789 | /* style inject shadow dom */ 790 | 791 | 792 | 793 | const __vue_component__ = /*#__PURE__*/normalizeComponent( 794 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 795 | __vue_inject_styles__, 796 | __vue_script__, 797 | __vue_scope_id__, 798 | __vue_is_functional_template__, 799 | __vue_module_identifier__, 800 | false, 801 | undefined, 802 | undefined, 803 | undefined 804 | ); 805 | 806 | export default __vue_component__; 807 | -------------------------------------------------------------------------------- /dist/vue-scrollama.min.js: -------------------------------------------------------------------------------- 1 | var VueScrollama=function(e){"use strict";function t(e){return"scrollama__debug-offset--"+e}function n(e){!function(e){var n=e.id,r=e.offsetVal,o=e.stepClass,i=document.createElement("div");i.id=t(n),i.className="scrollama__debug-offset",i.style.position="fixed",i.style.left="0",i.style.width="100%",i.style.height="0",i.style.borderTop="2px dashed black",i.style.zIndex="9999";var s=document.createElement("p");s.innerHTML='".'+o+'" trigger: '+r+"",s.style.fontSize="12px",s.style.fontFamily="monospace",s.style.color="black",s.style.margin="0",s.style.padding="6px",i.appendChild(s),document.body.appendChild(i)}({id:e.id,offsetVal:e.offsetVal,stepClass:e.stepEl[0].className})}function r(e){var t=e.id,n=e.index,r=e.state,o="scrollama__debug-step--"+t+"-"+n,i=document.getElementById(o+"_above"),s=document.getElementById(o+"_below"),a="enter"===r?"block":"none";i&&(i.style.display=a),s&&(s.style.display=a)}function o(){var e=["stepAbove","stepBelow","stepProgress","viewportAbove","viewportBelow"],o={},i={},s=null,a=[],c=[],l=[],f=[],u=0,d=0,p=0,v=0,g=0,m=0,h=!1,b=!1,x=!1,w=!1,y=!1,_=!1,E="down",S="percent",C=[];function M(e){console.error("scrollama error: "+e)}function R(){o={stepEnter:function(){},stepExit:function(){},stepProgress:function(){}},i={}}function I(e){return e.getBoundingClientRect().top+window.pageYOffset-(document.body.clientTop||0)}function O(e){return+e.getAttribute("data-scrollama-index")}function $(){window.pageYOffset>g?E="down":window.pageYOffsete;o-=1){var i=f[o];"enter"===i.state&&P(a[o],"up"),"down"===i.direction&&(N(a[o],"up",!1),P(a[o],"up"))}}function N(e,t,n){void 0===n&&(n=!0);var i=O(e),a={element:e,index:i,direction:t};f[i].direction=t,f[i].state="enter",y&&n&&"down"===t&&H(i,"above"),y&&n&&"up"===t&&H(i,"below"),o.stepEnter&&!C[i]&&(o.stepEnter(a,f),x&&r({id:s,index:i,state:"enter"}),_&&(C[i]=!0)),w&&B(e)}function P(e,t){var n=O(e),i={element:e,index:n,direction:t};w&&("down"===t&&f[n].progress<1?B(e,1):"up"===t&&f[n].progress>0&&B(e,0)),f[n].direction=t,f[n].state="exit",o.stepExit(i,f),x&&r({id:s,index:n,state:"exit"})}function k(e){var t=e[0];$();var n=t.isIntersecting,r=t.boundingClientRect,o=t.target,i=r.top,s=r.bottom,a=i-d,c=s-d,l=O(o),u=f[l];n&&a<=0&&c>=0&&"down"===E&&"enter"!==u.state&&N(o,E),!n&&a>0&&"up"===E&&"enter"===u.state&&P(o,E)}function F(e){var t=e[0];$();var n=t.isIntersecting,r=t.boundingClientRect,o=t.target,i=r.top,s=r.bottom,a=i-d,c=s-d,l=O(o),u=f[l];n&&a<=0&&c>=0&&"up"===E&&"enter"!==u.state&&N(o,E),!n&&c<0&&"down"===E&&"enter"===u.state&&P(o,E)}function z(e){var t=e[0];$();var n=t.isIntersecting,r=t.target,o=O(r),i=f[o];n&&"down"===E&&"down"!==i.direction&&"enter"!==i.state&&(N(r,"down"),P(r,"down"))}function q(e){var t=e[0];$();var n=t.isIntersecting,r=t.target,o=O(r),i=f[o];n&&"up"===E&&"down"===i.direction&&"enter"!==i.state&&(N(r,"up"),P(r,"up"))}function Y(e){var t=e[0];$();var n=t.isIntersecting,r=t.intersectionRatio,o=t.boundingClientRect,i=t.target,s=o.bottom;n&&s-d>=0&&B(i,+r)}function j(){i.stepProgress=a.map((function(e,t){var n=c[t]-d+"px 0px "+(-p+d)+"px 0px",r=function(e){for(var t=Math.ceil(e/m),n=[],r=1/t,o=0;oe.clientHeight}(e)?e:D(e.parentNode))}var U={};return U.setup=function(e){var t=e.step,r=e.parent,o=e.offset;void 0===o&&(o=.5);var i=e.progress;void 0===i&&(i=!1);var c=e.threshold;void 0===c&&(c=4);var l=e.debug;void 0===l&&(l=!1);var d=e.order;void 0===d&&(d=!0);var p,v,g,b=e.once;if(void 0===b&&(b=!1),R(),v=(p="abcdefghijklmnopqrstuv").length,g=Date.now(),s=""+[0,0,0].map((function(e){return p[Math.floor(Math.random()*v)]})).join("")+g,!(a=function(e,t){return void 0===t&&(t=document),"string"==typeof e?Array.from(t.querySelectorAll(e)):e instanceof Element?[e]:e instanceof NodeList?Array.from(e):e instanceof Array?e:[]}(t,r)).length)return M("no step elements"),U;var E=a.reduce((function(e,t){return e||D(t.parentNode)}),!1);return E&&console.error("scrollama error: step elements cannot be children of a scrollable element. Remove any css on the parent element with overflow: scroll; or overflow: auto; on elements with fixed height.",E),x=l,w=i,y=d,_=b,U.offsetTrigger(o),m=Math.max(1,+c),h=!0,x&&n({id:s,stepEl:a,offsetVal:u}),a.forEach((function(e,t){return e.setAttribute("data-scrollama-index",t)})),f=a.map((function(){return{direction:null,state:null,progress:0}})),T(),U.enable(),U},U.resize=function(){return T(),U},U.enable=function(){return V(!0),U},U.disable=function(){return V(!1),U},U.destroy=function(){V(!1),R()},U.offsetTrigger=function(e){if(null===e)return u;if("number"==typeof e)S="percent",e>1&&M("offset value is greater than 1. Fallback to 1."),e<0&&M("offset value is lower than 0. Fallback to 0."),u=Math.min(Math.max(0,e),1);else if("string"==typeof e&&e.indexOf("px")>0){var t=+e.replace("px","");isNaN(t)?(M("offset value must be in 'px' format. Fallback to 0.5."),u=.5):(S="pixels",u=t)}else M("offset value does not include 'px'. Fallback to 0.5."),u=.5;return U},U.onStepEnter=function(e){return"function"==typeof e?o.stepEnter=e:M("onStepEnter requires a function"),U},U.onStepExit=function(e){return"function"==typeof e?o.stepExit=e:M("onStepExit requires a function"),U},U.onStepProgress=function(e){return"function"==typeof e?o.stepProgress=e:M("onStepProgress requires a function"),U},U}function i(e,t,n,r,o,i,s,a,c,l){"boolean"!=typeof s&&(c=a,a=s,s=!1);var f,u="function"==typeof n?n.options:n;if(e&&e.render&&(u.render=e.render,u.staticRenderFns=e.staticRenderFns,u._compiled=!0,o&&(u.functional=!0)),r&&(u._scopeId=r),i?(f=function(e){(e=e||this.$vnode&&this.$vnode.ssrContext||this.parent&&this.parent.$vnode&&this.parent.$vnode.ssrContext)||"undefined"==typeof __VUE_SSR_CONTEXT__||(e=__VUE_SSR_CONTEXT__),t&&t.call(this,c(e)),e&&e._registeredComponents&&e._registeredComponents.add(i)},u._ssrRegister=f):t&&(f=s?function(e){t.call(this,l(e,this.$root.$options.shadowRoot))}:function(e){t.call(this,a(e))}),f)if(u.functional){var d=u.render;u.render=function(e,t){return f.call(t),d(e,t)}}else{var p=u.beforeCreate;u.beforeCreate=p?[].concat(p,f):[f]}return n}var s=i({render:function(){var e=this,t=e.$createElement;return(e._self._c||t)("div",{staticClass:"scrollama__steps"},[e._t("default")],2)},staticRenderFns:[]},undefined,{inheritAttrs:!1,name:"Scrollama",mounted:function(){this._scroller=o(),this.setup()},beforeDestroy:function(){this._scroller.destroy()},computed:{opts:function(){return Object.assign({},{step:Array.from(this.$el.children),progress:!!this.$listeners["step-progress"]},this.$attrs)}},methods:{setup:function(){var e=this;this._scroller.destroy(),this._scroller.setup(this.opts).onStepProgress((function(t){e.$emit("step-progress",t)})).onStepEnter((function(t){e.$emit("step-enter",t)})).onStepExit((function(t){e.$emit("step-exit",t)})),window.addEventListener("resize",this.handleResize)},handleResize:function(){this._scroller.resize()}}},undefined,false,undefined,!1,void 0,void 0,void 0);return void 0!==typeof Vue&&Vue.component("Scrollama",s),e.default=s,Object.defineProperty(e,"__esModule",{value:!0}),e}({}); 2 | -------------------------------------------------------------------------------- /dist/vue-scrollama.umd.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : 3 | typeof define === 'function' && define.amd ? define(['exports'], factory) : 4 | (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.VueScrollama = {})); 5 | }(this, (function (exports) { 'use strict'; 6 | 7 | // DOM helper functions 8 | 9 | // public 10 | function selectAll(selector, parent = document) { 11 | if (typeof selector === 'string') { 12 | return Array.from(parent.querySelectorAll(selector)); 13 | } else if (selector instanceof Element) { 14 | return [selector]; 15 | } else if (selector instanceof NodeList) { 16 | return Array.from(selector); 17 | } else if (selector instanceof Array) { 18 | return selector; 19 | } 20 | return []; 21 | } 22 | 23 | function getOffsetId(id) { 24 | return `scrollama__debug-offset--${id}`; 25 | } 26 | 27 | // SETUP 28 | function setupOffset({ id, offsetVal, stepClass }) { 29 | const el = document.createElement("div"); 30 | el.id = getOffsetId(id); 31 | el.className = "scrollama__debug-offset"; 32 | el.style.position = "fixed"; 33 | el.style.left = "0"; 34 | el.style.width = "100%"; 35 | el.style.height = "0"; 36 | el.style.borderTop = "2px dashed black"; 37 | el.style.zIndex = "9999"; 38 | 39 | const p = document.createElement("p"); 40 | p.innerHTML = `".${stepClass}" trigger: ${offsetVal}`; 41 | p.style.fontSize = "12px"; 42 | p.style.fontFamily = "monospace"; 43 | p.style.color = "black"; 44 | p.style.margin = "0"; 45 | p.style.padding = "6px"; 46 | el.appendChild(p); 47 | document.body.appendChild(el); 48 | } 49 | 50 | function setup({ id, offsetVal, stepEl }) { 51 | const stepClass = stepEl[0].className; 52 | setupOffset({ id, offsetVal, stepClass }); 53 | } 54 | 55 | // UPDATE 56 | function update({ id, offsetMargin, offsetVal, format }) { 57 | const post = format === "pixels" ? "px" : ""; 58 | const idVal = getOffsetId(id); 59 | const el = document.getElementById(idVal); 60 | el.style.top = `${offsetMargin}px`; 61 | el.querySelector("span").innerText = `${offsetVal}${post}`; 62 | } 63 | 64 | function notifyStep({ id, index, state }) { 65 | const prefix = `scrollama__debug-step--${id}-${index}`; 66 | const elA = document.getElementById(`${prefix}_above`); 67 | const elB = document.getElementById(`${prefix}_below`); 68 | const display = state === "enter" ? "block" : "none"; 69 | 70 | if (elA) elA.style.display = display; 71 | if (elB) elB.style.display = display; 72 | } 73 | 74 | function scrollama() { 75 | const OBSERVER_NAMES = [ 76 | "stepAbove", 77 | "stepBelow", 78 | "stepProgress", 79 | "viewportAbove", 80 | "viewportBelow" 81 | ]; 82 | 83 | let cb = {}; 84 | let io = {}; 85 | 86 | let id = null; 87 | let stepEl = []; 88 | let stepOffsetHeight = []; 89 | let stepOffsetTop = []; 90 | let stepStates = []; 91 | 92 | let offsetVal = 0; 93 | let offsetMargin = 0; 94 | let viewH = 0; 95 | let pageH = 0; 96 | let previousYOffset = 0; 97 | let progressThreshold = 0; 98 | 99 | let isReady = false; 100 | let isEnabled = false; 101 | let isDebug = false; 102 | 103 | let progressMode = false; 104 | let preserveOrder = false; 105 | let triggerOnce = false; 106 | 107 | let direction = "down"; 108 | let format = "percent"; 109 | 110 | const exclude = []; 111 | 112 | /* HELPERS */ 113 | function err(msg) { 114 | console.error(`scrollama error: ${msg}`); 115 | } 116 | 117 | function reset() { 118 | cb = { 119 | stepEnter: () => {}, 120 | stepExit: () => {}, 121 | stepProgress: () => {} 122 | }; 123 | io = {}; 124 | } 125 | 126 | function generateInstanceID() { 127 | const a = "abcdefghijklmnopqrstuv"; 128 | const l = a.length; 129 | const t = Date.now(); 130 | const r = [0, 0, 0].map(d => a[Math.floor(Math.random() * l)]).join(""); 131 | return `${r}${t}`; 132 | } 133 | 134 | function getOffsetTop(el) { 135 | const { top } = el.getBoundingClientRect(); 136 | const scrollTop = window.pageYOffset; 137 | const clientTop = document.body.clientTop || 0; 138 | return top + scrollTop - clientTop; 139 | } 140 | 141 | function getPageHeight() { 142 | const { body } = document; 143 | const html = document.documentElement; 144 | 145 | return Math.max( 146 | body.scrollHeight, 147 | body.offsetHeight, 148 | html.clientHeight, 149 | html.scrollHeight, 150 | html.offsetHeight 151 | ); 152 | } 153 | 154 | function getIndex(element) { 155 | return +element.getAttribute("data-scrollama-index"); 156 | } 157 | 158 | function updateDirection() { 159 | if (window.pageYOffset > previousYOffset) direction = "down"; 160 | else if (window.pageYOffset < previousYOffset) direction = "up"; 161 | previousYOffset = window.pageYOffset; 162 | } 163 | 164 | function disconnectObserver(name) { 165 | if (io[name]) io[name].forEach(d => d.disconnect()); 166 | } 167 | 168 | function handleResize() { 169 | viewH = window.innerHeight; 170 | pageH = getPageHeight(); 171 | 172 | const mult = format === "pixels" ? 1 : viewH; 173 | offsetMargin = offsetVal * mult; 174 | 175 | if (isReady) { 176 | stepOffsetHeight = stepEl.map(el => el.getBoundingClientRect().height); 177 | stepOffsetTop = stepEl.map(getOffsetTop); 178 | if (isEnabled) updateIO(); 179 | } 180 | 181 | if (isDebug) update({ id, offsetMargin, offsetVal, format }); 182 | } 183 | 184 | function handleEnable(enable) { 185 | if (enable && !isEnabled) { 186 | // enable a disabled scroller 187 | if (isReady) { 188 | // enable a ready scroller 189 | updateIO(); 190 | } else { 191 | // can't enable an unready scroller 192 | err("scrollama error: enable() called before scroller was ready"); 193 | isEnabled = false; 194 | return; // all is not well, don't set the requested state 195 | } 196 | } 197 | if (!enable && isEnabled) { 198 | // disable an enabled scroller 199 | OBSERVER_NAMES.forEach(disconnectObserver); 200 | } 201 | isEnabled = enable; // all is well, set requested state 202 | } 203 | 204 | function createThreshold(height) { 205 | const count = Math.ceil(height / progressThreshold); 206 | const t = []; 207 | const ratio = 1 / count; 208 | for (let i = 0; i < count; i += 1) { 209 | t.push(i * ratio); 210 | } 211 | return t; 212 | } 213 | 214 | /* NOTIFY CALLBACKS */ 215 | function notifyStepProgress(element, progress) { 216 | const index = getIndex(element); 217 | if (progress !== undefined) stepStates[index].progress = progress; 218 | const resp = { element, index, progress: stepStates[index].progress }; 219 | 220 | if (stepStates[index].state === "enter") cb.stepProgress(resp); 221 | } 222 | 223 | function notifyOthers(index, location) { 224 | if (location === "above") { 225 | // check if steps above/below were skipped and should be notified first 226 | for (let i = 0; i < index; i += 1) { 227 | const ss = stepStates[i]; 228 | if (ss.state !== "enter" && ss.direction !== "down") { 229 | notifyStepEnter(stepEl[i], "down", false); 230 | notifyStepExit(stepEl[i], "down"); 231 | } else if (ss.state === "enter") notifyStepExit(stepEl[i], "down"); 232 | // else if (ss.direction === 'up') { 233 | // notifyStepEnter(stepEl[i], 'down', false); 234 | // notifyStepExit(stepEl[i], 'down'); 235 | // } 236 | } 237 | } else if (location === "below") { 238 | for (let i = stepStates.length - 1; i > index; i -= 1) { 239 | const ss = stepStates[i]; 240 | if (ss.state === "enter") { 241 | notifyStepExit(stepEl[i], "up"); 242 | } 243 | if (ss.direction === "down") { 244 | notifyStepEnter(stepEl[i], "up", false); 245 | notifyStepExit(stepEl[i], "up"); 246 | } 247 | } 248 | } 249 | } 250 | 251 | function notifyStepEnter(element, dir, check = true) { 252 | const index = getIndex(element); 253 | const resp = { element, index, direction: dir }; 254 | 255 | // store most recent trigger 256 | stepStates[index].direction = dir; 257 | stepStates[index].state = "enter"; 258 | if (preserveOrder && check && dir === "down") notifyOthers(index, "above"); 259 | 260 | if (preserveOrder && check && dir === "up") notifyOthers(index, "below"); 261 | 262 | if (cb.stepEnter && !exclude[index]) { 263 | cb.stepEnter(resp, stepStates); 264 | if (isDebug) notifyStep({ id, index, state: "enter" }); 265 | if (triggerOnce) exclude[index] = true; 266 | } 267 | 268 | if (progressMode) notifyStepProgress(element); 269 | } 270 | 271 | function notifyStepExit(element, dir) { 272 | const index = getIndex(element); 273 | const resp = { element, index, direction: dir }; 274 | 275 | if (progressMode) { 276 | if (dir === "down" && stepStates[index].progress < 1) 277 | notifyStepProgress(element, 1); 278 | else if (dir === "up" && stepStates[index].progress > 0) 279 | notifyStepProgress(element, 0); 280 | } 281 | 282 | // store most recent trigger 283 | stepStates[index].direction = dir; 284 | stepStates[index].state = "exit"; 285 | 286 | cb.stepExit(resp, stepStates); 287 | if (isDebug) notifyStep({ id, index, state: "exit" }); 288 | } 289 | 290 | /* OBSERVER - INTERSECT HANDLING */ 291 | // this is good for entering while scrolling down + leaving while scrolling up 292 | function intersectStepAbove([entry]) { 293 | updateDirection(); 294 | const { isIntersecting, boundingClientRect, target } = entry; 295 | 296 | // bottom = bottom edge of element from top of viewport 297 | // bottomAdjusted = bottom edge of element from trigger 298 | const { top, bottom } = boundingClientRect; 299 | const topAdjusted = top - offsetMargin; 300 | const bottomAdjusted = bottom - offsetMargin; 301 | const index = getIndex(target); 302 | const ss = stepStates[index]; 303 | 304 | // entering above is only when topAdjusted is negative 305 | // and bottomAdjusted is positive 306 | if ( 307 | isIntersecting && 308 | topAdjusted <= 0 && 309 | bottomAdjusted >= 0 && 310 | direction === "down" && 311 | ss.state !== "enter" 312 | ) 313 | notifyStepEnter(target, direction); 314 | 315 | // exiting from above is when topAdjusted is positive and not intersecting 316 | if ( 317 | !isIntersecting && 318 | topAdjusted > 0 && 319 | direction === "up" && 320 | ss.state === "enter" 321 | ) 322 | notifyStepExit(target, direction); 323 | } 324 | 325 | // this is good for entering while scrolling up + leaving while scrolling down 326 | function intersectStepBelow([entry]) { 327 | updateDirection(); 328 | const { isIntersecting, boundingClientRect, target } = entry; 329 | 330 | // bottom = bottom edge of element from top of viewport 331 | // bottomAdjusted = bottom edge of element from trigger 332 | const { top, bottom } = boundingClientRect; 333 | const topAdjusted = top - offsetMargin; 334 | const bottomAdjusted = bottom - offsetMargin; 335 | const index = getIndex(target); 336 | const ss = stepStates[index]; 337 | 338 | // entering below is only when bottomAdjusted is positive 339 | // and topAdjusted is negative 340 | if ( 341 | isIntersecting && 342 | topAdjusted <= 0 && 343 | bottomAdjusted >= 0 && 344 | direction === "up" && 345 | ss.state !== "enter" 346 | ) 347 | notifyStepEnter(target, direction); 348 | 349 | // exiting from above is when bottomAdjusted is negative and not intersecting 350 | if ( 351 | !isIntersecting && 352 | bottomAdjusted < 0 && 353 | direction === "down" && 354 | ss.state === "enter" 355 | ) 356 | notifyStepExit(target, direction); 357 | } 358 | 359 | /* 360 | if there is a scroll event where a step never intersects (therefore 361 | skipping an enter/exit trigger), use this fallback to detect if it is 362 | in view 363 | */ 364 | function intersectViewportAbove([entry]) { 365 | updateDirection(); 366 | const { isIntersecting, target } = entry; 367 | const index = getIndex(target); 368 | const ss = stepStates[index]; 369 | 370 | if ( 371 | isIntersecting && 372 | direction === "down" && 373 | ss.direction !== "down" && 374 | ss.state !== "enter" 375 | ) { 376 | notifyStepEnter(target, "down"); 377 | notifyStepExit(target, "down"); 378 | } 379 | } 380 | 381 | function intersectViewportBelow([entry]) { 382 | updateDirection(); 383 | const { isIntersecting, target } = entry; 384 | const index = getIndex(target); 385 | const ss = stepStates[index]; 386 | if ( 387 | isIntersecting && 388 | direction === "up" && 389 | ss.direction === "down" && 390 | ss.state !== "enter" 391 | ) { 392 | notifyStepEnter(target, "up"); 393 | notifyStepExit(target, "up"); 394 | } 395 | } 396 | 397 | function intersectStepProgress([entry]) { 398 | updateDirection(); 399 | const { 400 | isIntersecting, 401 | intersectionRatio, 402 | boundingClientRect, 403 | target 404 | } = entry; 405 | const { bottom } = boundingClientRect; 406 | const bottomAdjusted = bottom - offsetMargin; 407 | if (isIntersecting && bottomAdjusted >= 0) { 408 | notifyStepProgress(target, +intersectionRatio); 409 | } 410 | } 411 | 412 | /* OBSERVER - CREATION */ 413 | // jump into viewport 414 | function updateViewportAboveIO() { 415 | io.viewportAbove = stepEl.map((el, i) => { 416 | const marginTop = pageH - stepOffsetTop[i]; 417 | const marginBottom = offsetMargin - viewH - stepOffsetHeight[i]; 418 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 419 | const options = { rootMargin }; 420 | // console.log(options); 421 | const obs = new IntersectionObserver(intersectViewportAbove, options); 422 | obs.observe(el); 423 | return obs; 424 | }); 425 | } 426 | 427 | function updateViewportBelowIO() { 428 | io.viewportBelow = stepEl.map((el, i) => { 429 | const marginTop = -offsetMargin - stepOffsetHeight[i]; 430 | const marginBottom = offsetMargin - viewH + stepOffsetHeight[i] + pageH; 431 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 432 | const options = { rootMargin }; 433 | // console.log(options); 434 | const obs = new IntersectionObserver(intersectViewportBelow, options); 435 | obs.observe(el); 436 | return obs; 437 | }); 438 | } 439 | 440 | // look above for intersection 441 | function updateStepAboveIO() { 442 | io.stepAbove = stepEl.map((el, i) => { 443 | const marginTop = -offsetMargin + stepOffsetHeight[i]; 444 | const marginBottom = offsetMargin - viewH; 445 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 446 | const options = { rootMargin }; 447 | // console.log(options); 448 | const obs = new IntersectionObserver(intersectStepAbove, options); 449 | obs.observe(el); 450 | return obs; 451 | }); 452 | } 453 | 454 | // look below for intersection 455 | function updateStepBelowIO() { 456 | io.stepBelow = stepEl.map((el, i) => { 457 | const marginTop = -offsetMargin; 458 | const marginBottom = offsetMargin - viewH + stepOffsetHeight[i]; 459 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 460 | const options = { rootMargin }; 461 | // console.log(options); 462 | const obs = new IntersectionObserver(intersectStepBelow, options); 463 | obs.observe(el); 464 | return obs; 465 | }); 466 | } 467 | 468 | // progress progress tracker 469 | function updateStepProgressIO() { 470 | io.stepProgress = stepEl.map((el, i) => { 471 | const marginTop = stepOffsetHeight[i] - offsetMargin; 472 | const marginBottom = -viewH + offsetMargin; 473 | const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; 474 | const threshold = createThreshold(stepOffsetHeight[i]); 475 | const options = { rootMargin, threshold }; 476 | // console.log(options); 477 | const obs = new IntersectionObserver(intersectStepProgress, options); 478 | obs.observe(el); 479 | return obs; 480 | }); 481 | } 482 | 483 | function updateIO() { 484 | OBSERVER_NAMES.forEach(disconnectObserver); 485 | 486 | updateViewportAboveIO(); 487 | updateViewportBelowIO(); 488 | updateStepAboveIO(); 489 | updateStepBelowIO(); 490 | 491 | if (progressMode) updateStepProgressIO(); 492 | } 493 | 494 | /* SETUP FUNCTIONS */ 495 | 496 | function indexSteps() { 497 | stepEl.forEach((el, i) => el.setAttribute("data-scrollama-index", i)); 498 | } 499 | 500 | function setupStates() { 501 | stepStates = stepEl.map(() => ({ 502 | direction: null, 503 | state: null, 504 | progress: 0 505 | })); 506 | } 507 | 508 | function addDebug() { 509 | if (isDebug) setup({ id, stepEl, offsetVal }); 510 | } 511 | 512 | function isYScrollable(element) { 513 | const style = window.getComputedStyle(element); 514 | return ( 515 | (style.overflowY === "scroll" || style.overflowY === "auto") && 516 | element.scrollHeight > element.clientHeight 517 | ); 518 | } 519 | 520 | // recursively search the DOM for a parent container with overflowY: scroll and fixed height 521 | // ends at document 522 | function anyScrollableParent(element) { 523 | if (element && element.nodeType === 1) { 524 | // check dom elements only, stop at document 525 | // if a scrollable element is found return the element 526 | // if not continue to next parent 527 | return isYScrollable(element) 528 | ? element 529 | : anyScrollableParent(element.parentNode); 530 | } 531 | return false; // didn't find a scrollable parent 532 | } 533 | 534 | const S = {}; 535 | 536 | S.setup = ({ 537 | step, 538 | parent, 539 | offset = 0.5, 540 | progress = false, 541 | threshold = 4, 542 | debug = false, 543 | order = true, 544 | once = false 545 | }) => { 546 | reset(); 547 | // create id unique to this scrollama instance 548 | id = generateInstanceID(); 549 | 550 | stepEl = selectAll(step, parent); 551 | 552 | if (!stepEl.length) { 553 | err("no step elements"); 554 | return S; 555 | } 556 | 557 | // ensure that no step has a scrollable parent element in the dom tree 558 | // check current step for scrollable parent 559 | // assume no scrollable parents to start 560 | const scrollableParent = stepEl.reduce( 561 | (foundScrollable, s) => 562 | foundScrollable || anyScrollableParent(s.parentNode), 563 | false 564 | ); 565 | if (scrollableParent) { 566 | console.error( 567 | "scrollama error: step elements cannot be children of a scrollable element. Remove any css on the parent element with overflow: scroll; or overflow: auto; on elements with fixed height.", 568 | scrollableParent 569 | ); 570 | } 571 | 572 | // options 573 | isDebug = debug; 574 | progressMode = progress; 575 | preserveOrder = order; 576 | triggerOnce = once; 577 | 578 | S.offsetTrigger(offset); 579 | progressThreshold = Math.max(1, +threshold); 580 | 581 | isReady = true; 582 | 583 | // customize 584 | addDebug(); 585 | indexSteps(); 586 | setupStates(); 587 | handleResize(); 588 | S.enable(); 589 | return S; 590 | }; 591 | 592 | S.resize = () => { 593 | handleResize(); 594 | return S; 595 | }; 596 | 597 | S.enable = () => { 598 | handleEnable(true); 599 | return S; 600 | }; 601 | 602 | S.disable = () => { 603 | handleEnable(false); 604 | return S; 605 | }; 606 | 607 | S.destroy = () => { 608 | handleEnable(false); 609 | reset(); 610 | }; 611 | 612 | S.offsetTrigger = x => { 613 | if (x === null) return offsetVal; 614 | 615 | if (typeof x === "number") { 616 | format = "percent"; 617 | if (x > 1) err("offset value is greater than 1. Fallback to 1."); 618 | if (x < 0) err("offset value is lower than 0. Fallback to 0."); 619 | offsetVal = Math.min(Math.max(0, x), 1); 620 | } else if (typeof x === "string" && x.indexOf("px") > 0) { 621 | const v = +x.replace("px", ""); 622 | if (!isNaN(v)) { 623 | format = "pixels"; 624 | offsetVal = v; 625 | } else { 626 | err("offset value must be in 'px' format. Fallback to 0.5."); 627 | offsetVal = 0.5; 628 | } 629 | } else { 630 | err("offset value does not include 'px'. Fallback to 0.5."); 631 | offsetVal = 0.5; 632 | } 633 | return S; 634 | }; 635 | 636 | S.onStepEnter = f => { 637 | if (typeof f === "function") cb.stepEnter = f; 638 | else err("onStepEnter requires a function"); 639 | return S; 640 | }; 641 | 642 | S.onStepExit = f => { 643 | if (typeof f === "function") cb.stepExit = f; 644 | else err("onStepExit requires a function"); 645 | return S; 646 | }; 647 | 648 | S.onStepProgress = f => { 649 | if (typeof f === "function") cb.stepProgress = f; 650 | else err("onStepProgress requires a function"); 651 | return S; 652 | }; 653 | 654 | return S; 655 | } 656 | 657 | // 658 | 659 | var script = { 660 | inheritAttrs: false, 661 | name: 'Scrollama', 662 | mounted () { 663 | this._scroller = scrollama(); 664 | this.setup(); 665 | }, 666 | beforeDestroy() { 667 | this._scroller.destroy(); 668 | }, 669 | computed: { 670 | opts() { 671 | return Object.assign({}, { 672 | step: Array.from(this.$el.children), 673 | progress: !!this.$listeners['step-progress'] 674 | }, this.$attrs); 675 | } 676 | }, 677 | methods: { 678 | setup() { 679 | this._scroller.destroy(); 680 | 681 | this._scroller 682 | .setup(this.opts) 683 | .onStepProgress(resp => { 684 | this.$emit('step-progress', resp); 685 | }) 686 | .onStepEnter(resp => { 687 | this.$emit('step-enter', resp); 688 | }) 689 | .onStepExit(resp => { 690 | this.$emit('step-exit', resp); 691 | }); 692 | 693 | window.addEventListener('resize', this.handleResize); 694 | }, 695 | handleResize () { 696 | this._scroller.resize(); 697 | } 698 | } 699 | }; 700 | 701 | function normalizeComponent(template, style, script, scopeId, isFunctionalTemplate, moduleIdentifier /* server only */, shadowMode, createInjector, createInjectorSSR, createInjectorShadow) { 702 | if (typeof shadowMode !== 'boolean') { 703 | createInjectorSSR = createInjector; 704 | createInjector = shadowMode; 705 | shadowMode = false; 706 | } 707 | // Vue.extend constructor export interop. 708 | const options = typeof script === 'function' ? script.options : script; 709 | // render functions 710 | if (template && template.render) { 711 | options.render = template.render; 712 | options.staticRenderFns = template.staticRenderFns; 713 | options._compiled = true; 714 | // functional template 715 | if (isFunctionalTemplate) { 716 | options.functional = true; 717 | } 718 | } 719 | // scopedId 720 | if (scopeId) { 721 | options._scopeId = scopeId; 722 | } 723 | let hook; 724 | if (moduleIdentifier) { 725 | // server build 726 | hook = function (context) { 727 | // 2.3 injection 728 | context = 729 | context || // cached call 730 | (this.$vnode && this.$vnode.ssrContext) || // stateful 731 | (this.parent && this.parent.$vnode && this.parent.$vnode.ssrContext); // functional 732 | // 2.2 with runInNewContext: true 733 | if (!context && typeof __VUE_SSR_CONTEXT__ !== 'undefined') { 734 | context = __VUE_SSR_CONTEXT__; 735 | } 736 | // inject component styles 737 | if (style) { 738 | style.call(this, createInjectorSSR(context)); 739 | } 740 | // register component module identifier for async chunk inference 741 | if (context && context._registeredComponents) { 742 | context._registeredComponents.add(moduleIdentifier); 743 | } 744 | }; 745 | // used by ssr in case component is cached and beforeCreate 746 | // never gets called 747 | options._ssrRegister = hook; 748 | } 749 | else if (style) { 750 | hook = shadowMode 751 | ? function (context) { 752 | style.call(this, createInjectorShadow(context, this.$root.$options.shadowRoot)); 753 | } 754 | : function (context) { 755 | style.call(this, createInjector(context)); 756 | }; 757 | } 758 | if (hook) { 759 | if (options.functional) { 760 | // register for functional component in vue file 761 | const originalRender = options.render; 762 | options.render = function renderWithStyleInjection(h, context) { 763 | hook.call(context); 764 | return originalRender(h, context); 765 | }; 766 | } 767 | else { 768 | // inject component registration as beforeCreate hook 769 | const existing = options.beforeCreate; 770 | options.beforeCreate = existing ? [].concat(existing, hook) : [hook]; 771 | } 772 | } 773 | return script; 774 | } 775 | 776 | /* script */ 777 | const __vue_script__ = script; 778 | 779 | /* template */ 780 | var __vue_render__ = function () {var _vm=this;var _h=_vm.$createElement;var _c=_vm._self._c||_h;return _c('div',{staticClass:"scrollama__steps"},[_vm._t("default")],2)}; 781 | var __vue_staticRenderFns__ = []; 782 | 783 | /* style */ 784 | const __vue_inject_styles__ = undefined; 785 | /* scoped */ 786 | const __vue_scope_id__ = undefined; 787 | /* module identifier */ 788 | const __vue_module_identifier__ = undefined; 789 | /* functional template */ 790 | const __vue_is_functional_template__ = false; 791 | /* style inject */ 792 | 793 | /* style inject SSR */ 794 | 795 | /* style inject shadow dom */ 796 | 797 | 798 | 799 | const __vue_component__ = /*#__PURE__*/normalizeComponent( 800 | { render: __vue_render__, staticRenderFns: __vue_staticRenderFns__ }, 801 | __vue_inject_styles__, 802 | __vue_script__, 803 | __vue_scope_id__, 804 | __vue_is_functional_template__, 805 | __vue_module_identifier__, 806 | false, 807 | undefined, 808 | undefined, 809 | undefined 810 | ); 811 | 812 | exports.default = __vue_component__; 813 | 814 | Object.defineProperty(exports, '__esModule', { value: true }); 815 | 816 | }))); 817 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vue-scrollama", 3 | "version": "2.0.6", 4 | "description": "Easy scroll driven interactions (aka scrollytelling) with Vue + Scrollama", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/shenoy/vue-scrollama.git" 8 | }, 9 | "keywords": [ 10 | "vue", 11 | "scrollama", 12 | "scroll", 13 | "scroll-driven", 14 | "scrollytelling" 15 | ], 16 | "author": "Vignesh Shenoy ", 17 | "bugs": { 18 | "url": "https://github.com/shenoy/vue-scrollama/issues" 19 | }, 20 | "license": "MIT", 21 | "scripts": { 22 | "build": "rollup -c --environment BUILD:production" 23 | }, 24 | "files": [ 25 | "dist/*", 26 | "src/*", 27 | "*.json" 28 | ], 29 | "main": "./dist/vue-scrollama.umd.js", 30 | "module": "./dist/vue-scrollama.esm.js", 31 | "unpkg": "./dist/vue-scrollama.min.js", 32 | "browser": { 33 | "./sfc": "src/Scrollama.vue" 34 | }, 35 | "dependencies": { 36 | "scrollama": "^2.2.2" 37 | }, 38 | "devDependencies": { 39 | "@rollup/plugin-buble": "^0.21.3", 40 | "@rollup/plugin-commonjs": "^18.0.0", 41 | "@rollup/plugin-node-resolve": "^11.2.1", 42 | "postcss": "^8.2.13", 43 | "rollup": "^2.46.0", 44 | "rollup-plugin-terser": "^7.0.2", 45 | "rollup-plugin-vue": "^5.1.9", 46 | "vue": "^2.6.12", 47 | "vue-template-compiler": "^2.6.12" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = {} 2 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import vue from 'rollup-plugin-vue'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 4 | import buble from "@rollup/plugin-buble"; 5 | import { terser } from 'rollup-plugin-terser'; 6 | 7 | export default [ 8 | { 9 | input: 'src/index.js', 10 | output: { 11 | format: 'umd', 12 | file: 'dist/vue-scrollama.umd.js', 13 | name: 'VueScrollama', 14 | exports: 'named' 15 | }, 16 | plugins: [ 17 | nodeResolve({exportConditions: ['node']}), 18 | commonjs({include: 'node_modules/**'}), 19 | vue() 20 | ] 21 | }, 22 | // ESM build to be used with webpack/rollup. 23 | { 24 | input: 'src/index.js', 25 | output: { 26 | format: 'esm', 27 | file: 'dist/vue-scrollama.esm.js', 28 | exports: 'named' 29 | }, 30 | plugins: [ 31 | nodeResolve({exportConditions: ['node']}), 32 | commonjs({include: 'node_modules/**'}), 33 | vue() 34 | ] 35 | }, 36 | // Browser build. 37 | { 38 | input: 'src/wrapper.js', 39 | output: { 40 | format: 'iife', 41 | file: 'dist/vue-scrollama.min.js', 42 | name: 'VueScrollama', 43 | exports: 'named' 44 | }, 45 | plugins: [ 46 | nodeResolve({exportConditions: ['node']}), 47 | commonjs({include: 'node_modules/**'}), 48 | vue(), 49 | buble(), 50 | terser() 51 | ] 52 | } 53 | ]; 54 | -------------------------------------------------------------------------------- /src/Scrollama.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 52 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Scrollama from "./Scrollama.vue"; 2 | 3 | export default Scrollama; 4 | -------------------------------------------------------------------------------- /src/wrapper.js: -------------------------------------------------------------------------------- 1 | import Scrollama from "./Scrollama.vue"; 2 | 3 | if (typeof Vue !== undefined) { 4 | Vue.component('Scrollama', Scrollama); 5 | } 6 | 7 | export default Scrollama; 8 | --------------------------------------------------------------------------------