├── .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 |
` elements as steps and a `step-enter` event
34 |
35 | ```vue
36 |
37 |
38 | ...
// classes like .step-1 may be used to adjust the style and dimensions of a step
39 | ...
// data-* attributes can be helpful to store instructions to be used in handlers
40 | ...
41 |
42 |
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 |
72 |
73 | ...
74 |
75 |
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.pageYOffset
e;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 |
2 |
3 |
4 |
5 |
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 |
--------------------------------------------------------------------------------