UNPKG

scrollama

Version:

Lightweight scrollytelling library using IntersectionObserver

579 lines (500 loc) 16.1 kB
import { selectAll } from './dom'; import * as bug from './debug'; function scrollama() { const OBSERVER_NAMES = [ 'stepAbove', 'stepBelow', 'stepProgress', 'viewportAbove', 'viewportBelow', ]; const cb = { stepEnter: () => {}, stepExit: () => {}, stepProgress: () => {}, }; const io = {}; let id = null; let stepEl = []; let stepOffsetHeight = []; let stepOffsetTop = []; let stepStates = []; let offsetVal = 0; let offsetMargin = 0; let viewH = 0; let pageH = 0; let previousYOffset = 0; let progressThreshold = 0; let isReady = false; let isEnabled = false; let isDebug = false; let progressMode = false; let preserveOrder = false; let triggerOnce = false; let direction = 'down'; const exclude = []; /* HELPERS */ function generateInstanceID() { const a = 'abcdefghijklmnopqrstuv'; const l = a.length; const t = Date.now(); const r = [0, 0, 0].map(d => a[Math.floor(Math.random() * l)]).join(''); return `${r}${t}`; } function getOffsetTop(el) { const { top } = el.getBoundingClientRect(); const scrollTop = window.pageYOffset; const clientTop = document.body.clientTop || 0; return top + scrollTop - clientTop; } function getPageHeight() { const { body } = document; const html = document.documentElement; return Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight ); } function getIndex(element) { return +element.getAttribute('data-scrollama-index'); } function updateDirection() { if (window.pageYOffset > previousYOffset) direction = 'down'; else if (window.pageYOffset < previousYOffset) direction = 'up'; previousYOffset = window.pageYOffset; } function disconnectObserver(name) { if (io[name]) io[name].forEach(d => d.disconnect()); } function handleResize() { viewH = window.innerHeight; pageH = getPageHeight(); offsetMargin = offsetVal * viewH; if (isReady) { stepOffsetHeight = stepEl.map(el => el.getBoundingClientRect().height); stepOffsetTop = stepEl.map(getOffsetTop); if (isEnabled) updateIO(); } if (isDebug) bug.update({ id, stepOffsetHeight, offsetMargin, offsetVal }); } function handleEnable(enable) { if (enable && !isEnabled) { // enable a disabled scroller if (isReady) { // enable a ready scroller updateIO(); } else { // can't enable an unready scroller console.error( 'scrollama error: enable() called before scroller was ready' ); isEnabled = false; return; // all is not well, don't set the requested state } } if (!enable && isEnabled) { // disable an enabled scroller OBSERVER_NAMES.forEach(disconnectObserver); } isEnabled = enable; // all is well, set requested state } function createThreshold(height) { const count = Math.ceil(height / progressThreshold); const t = []; const ratio = 1 / count; for (let i = 0; i < count; i += 1) { t.push(i * ratio); } return t; } /* NOTIFY CALLBACKS */ function notifyStepProgress(element, progress) { const index = getIndex(element); if (progress !== undefined) stepStates[index].progress = progress; const resp = { element, index, progress: stepStates[index].progress }; if (stepStates[index].state === 'enter') cb.stepProgress(resp); } function notifyOthers(index, location) { if (location === 'above') { // check if steps above/below were skipped and should be notified first for (let i = 0; i < index; i += 1) { const ss = stepStates[i]; if (ss.state !== 'enter' && ss.direction !== 'down') { notifyStepEnter(stepEl[i], 'down', false); notifyStepExit(stepEl[i], 'down'); } else if (ss.state === 'enter') notifyStepExit(stepEl[i], 'down'); // else if (ss.direction === 'up') { // notifyStepEnter(stepEl[i], 'down', false); // notifyStepExit(stepEl[i], 'down'); // } } } else if (location === 'below') { for (let i = stepStates.length - 1; i > index; i -= 1) { const ss = stepStates[i]; if (ss.state === 'enter') { notifyStepExit(stepEl[i], 'up'); } if (ss.direction === 'down') { notifyStepEnter(stepEl[i], 'up', false); notifyStepExit(stepEl[i], 'up'); } } } } function notifyStepEnter(element, dir, check = true) { const index = getIndex(element); const resp = { element, index, direction: dir }; // store most recent trigger stepStates[index].direction = dir; stepStates[index].state = 'enter'; if (preserveOrder && check && dir === 'down') notifyOthers(index, 'above'); if (preserveOrder && check && dir === 'up') notifyOthers(index, 'below'); if (cb.stepEnter && !exclude[index]) { cb.stepEnter(resp, stepStates); if (isDebug) bug.notifyStep({ id, index, state: 'enter' }); if (triggerOnce) exclude[index] = true; } if (progressMode) notifyStepProgress(element); } function notifyStepExit(element, dir) { const index = getIndex(element); const resp = { element, index, direction: dir }; if (progressMode) { if (dir === 'down' && stepStates[index].progress < 1) notifyStepProgress(element, 1); else if (dir === 'up' && stepStates[index].progress > 0) notifyStepProgress(element, 0); } // store most recent trigger stepStates[index].direction = dir; stepStates[index].state = 'exit'; cb.stepExit(resp, stepStates); if (isDebug) bug.notifyStep({ id, index, state: 'exit' }); } /* OBSERVER - INTERSECT HANDLING */ // this is good for entering while scrolling down + leaving while scrolling up function intersectStepAbove([entry]) { updateDirection(); const { isIntersecting, boundingClientRect, target } = entry; // bottom = bottom edge of element from top of viewport // bottomAdjusted = bottom edge of element from trigger const { top, bottom } = boundingClientRect; const topAdjusted = top - offsetMargin; const bottomAdjusted = bottom - offsetMargin; const index = getIndex(target); const ss = stepStates[index]; // entering above is only when topAdjusted is negative // and bottomAdjusted is positive if ( isIntersecting && topAdjusted <= 0 && bottomAdjusted >= 0 && direction === 'down' && ss.state !== 'enter' ) notifyStepEnter(target, direction); // exiting from above is when topAdjusted is positive and not intersecting if ( !isIntersecting && topAdjusted > 0 && direction === 'up' && ss.state === 'enter' ) notifyStepExit(target, direction); } // this is good for entering while scrolling up + leaving while scrolling down function intersectStepBelow([entry]) { updateDirection(); const { isIntersecting, boundingClientRect, target } = entry; // bottom = bottom edge of element from top of viewport // bottomAdjusted = bottom edge of element from trigger const { top, bottom } = boundingClientRect; const topAdjusted = top - offsetMargin; const bottomAdjusted = bottom - offsetMargin; const index = getIndex(target); const ss = stepStates[index]; // entering below is only when bottomAdjusted is positive // and topAdjusted is negative if ( isIntersecting && topAdjusted <= 0 && bottomAdjusted >= 0 && direction === 'up' && ss.state !== 'enter' ) notifyStepEnter(target, direction); // exiting from above is when bottomAdjusted is negative and not intersecting if ( !isIntersecting && bottomAdjusted < 0 && direction === 'down' && ss.state === 'enter' ) notifyStepExit(target, direction); } /* if there is a scroll event where a step never intersects (therefore skipping an enter/exit trigger), use this fallback to detect if it is in view */ function intersectViewportAbove([entry]) { updateDirection(); const { isIntersecting, target } = entry; const index = getIndex(target); const ss = stepStates[index]; if ( isIntersecting && direction === 'down' && ss.direction !== 'down' && ss.state !== 'enter' ) { notifyStepEnter(target, 'down'); notifyStepExit(target, 'down'); } } function intersectViewportBelow([entry]) { updateDirection(); const { isIntersecting, target } = entry; const index = getIndex(target); const ss = stepStates[index]; if ( isIntersecting && direction === 'up' && ss.direction === 'down' && ss.state !== 'enter' ) { notifyStepEnter(target, 'up'); notifyStepExit(target, 'up'); } } function intersectStepProgress([entry]) { updateDirection(); const { isIntersecting, intersectionRatio, boundingClientRect, target, } = entry; const { bottom } = boundingClientRect; const bottomAdjusted = bottom - offsetMargin; if (isIntersecting && bottomAdjusted >= 0) { notifyStepProgress(target, +intersectionRatio.toFixed(3)); } } /* OBSERVER - CREATION */ // jump into viewport function updateViewportAboveIO() { io.viewportAbove = stepEl.map((el, i) => { const marginTop = pageH - stepOffsetTop[i]; const marginBottom = offsetMargin - viewH - stepOffsetHeight[i]; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const options = { rootMargin }; // console.log(options); const obs = new IntersectionObserver(intersectViewportAbove, options); obs.observe(el); return obs; }); } function updateViewportBelowIO() { io.viewportBelow = stepEl.map((el, i) => { const marginTop = -offsetMargin - stepOffsetHeight[i]; const marginBottom = offsetMargin - viewH + stepOffsetHeight[i] + pageH; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const options = { rootMargin }; // console.log(options); const obs = new IntersectionObserver(intersectViewportBelow, options); obs.observe(el); return obs; }); } // look above for intersection function updateStepAboveIO() { io.stepAbove = stepEl.map((el, i) => { const marginTop = -offsetMargin + stepOffsetHeight[i]; const marginBottom = offsetMargin - viewH; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const options = { rootMargin }; // console.log(options); const obs = new IntersectionObserver(intersectStepAbove, options); obs.observe(el); return obs; }); } // look below for intersection function updateStepBelowIO() { io.stepBelow = stepEl.map((el, i) => { const marginTop = -offsetMargin; const marginBottom = offsetMargin - viewH + stepOffsetHeight[i]; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const options = { rootMargin }; // console.log(options); const obs = new IntersectionObserver(intersectStepBelow, options); obs.observe(el); return obs; }); } // progress progress tracker function updateStepProgressIO() { io.stepProgress = stepEl.map((el, i) => { const marginTop = stepOffsetHeight[i] - offsetMargin; const marginBottom = -viewH + offsetMargin; const rootMargin = `${marginTop}px 0px ${marginBottom}px 0px`; const threshold = createThreshold(stepOffsetHeight[i]); const options = { rootMargin, threshold }; // console.log(options); const obs = new IntersectionObserver(intersectStepProgress, options); obs.observe(el); return obs; }); } function updateIO() { OBSERVER_NAMES.forEach(disconnectObserver); updateViewportAboveIO(); updateViewportBelowIO(); updateStepAboveIO(); updateStepBelowIO(); if (progressMode) updateStepProgressIO(); } /* SETUP FUNCTIONS */ function indexSteps() { stepEl.forEach((el, i) => el.setAttribute('data-scrollama-index', i)); } function setupStates() { stepStates = stepEl.map(() => ({ direction: null, state: null, progress: 0, })); } function addDebug() { if (isDebug) bug.setup({ id, stepEl, offsetVal }); } function isYScrollable(element) { const style = window.getComputedStyle(element); return ( (style.overflowY === 'scroll' || style.overflowY === 'auto') && element.scrollHeight > element.clientHeight ); } // recursively search the DOM for a parent container with overflowY: scroll and fixed height // ends at document function anyScrollableParent(element) { if (element && element.nodeType === 1) { // check dom elements only, stop at document // if a scrollable element is found return the element // if not continue to next parent return isYScrollable(element) ? element : anyScrollableParent(element.parentNode); } return false; // didn't find a scrollable parent } const S = {}; S.setup = ({ step, offset = 0.5, progress = false, threshold = 4, debug = false, order = true, once = false, }) => { // create id unique to this scrollama instance id = generateInstanceID(); stepEl = selectAll(step); if (!stepEl.length) { console.error('scrollama error: no step elements'); return S; } // ensure that no step has a scrollable parent element in the dom tree // check current step for scrollable parent // assume no scrollable parents to start const scrollableParent = stepEl.reduce( (foundScrollable, s) => foundScrollable || anyScrollableParent(s.parentNode), false ); if (scrollableParent) { 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.', scrollableParent ); } // options isDebug = debug; progressMode = progress; preserveOrder = order; triggerOnce = once; S.offsetTrigger(offset); progressThreshold = Math.max(1, +threshold); isReady = true; // customize addDebug(); indexSteps(); setupStates(); handleResize(); S.enable(); return S; }; S.resize = () => { handleResize(); return S; }; S.enable = () => { handleEnable(true); return S; }; S.disable = () => { handleEnable(false); return S; }; S.destroy = () => { handleEnable(false); Object.keys(cb).forEach(c => { cb[c] = null; }); Object.keys(io).forEach(i => { io[i] = null; }); }; S.offsetTrigger = x => { if (x && !isNaN(x)) { if (x > 1) console.error( 'scrollama error: offset value is greater than 1. Fallbacks to 1.' ); if (x < 0) console.error( 'scrollama error: offset value is lower than 0. Fallbacks to 0.' ); offsetVal = Math.min(Math.max(0, x), 1); return S; } if (isNaN(x)) { console.error( 'scrollama error: offset value is not a number. Fallbacks to 0.' ); } return offsetVal; }; S.onStepEnter = f => { if (typeof f === 'function') cb.stepEnter = f; else console.error('scrollama error: onStepEnter requires a function'); return S; }; S.onStepExit = f => { if (typeof f === 'function') cb.stepExit = f; else console.error('scrollama error: onStepExit requires a function'); return S; }; S.onStepProgress = f => { if (typeof f === 'function') cb.stepProgress = f; else console.error('scrollama error: onStepProgress requires a function'); return S; }; return S; } export default scrollama;