├── .gitignore ├── example.gif ├── test ├── index.js └── index.html ├── package.json ├── dist ├── index.html ├── index.es.js ├── index.umd.js ├── index.js.map ├── index.es.js.map ├── index.umd.js.map ├── index.js ├── test.e31bb0bc.js └── test.e31bb0bc.js.map ├── README.md └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/aholachek/scrollbounce/HEAD/example.gif -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import initBounce from "../src/index" 2 | 3 | const ul = document.querySelector("ul") 4 | 5 | ;[...new Array(200).keys()].forEach(i => { 6 | const li = document.createElement("li") 7 | li.dataset.bounceId = `bounce-id-${i}` 8 | ul.appendChild(li) 9 | }) 10 | 11 | initBounce({ effectMultiplier: 3 }) 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "scrollbounce", 3 | "version": "0.0.5", 4 | "description": "Add a subtle bounce effect on mobile when the user scrolls", 5 | "source": "src/index.js", 6 | "main": "dist/index.js", 7 | "module": "dist/index.es.js", 8 | "unpkg": "dist/index.umd.js", 9 | "scripts": { 10 | "test": "parcel test/index.html", 11 | "build": "microbundle", 12 | "dev": "microbundle watch", 13 | "prepare": "yarn run build", 14 | "postpublish": "git push origin --tags" 15 | }, 16 | "author": "Alex Holachek", 17 | "license": "ISC", 18 | "dependencies": { 19 | "rebound": "^0.1.0" 20 | }, 21 | "devDependencies": { 22 | "microbundle": "^0.11.0", 23 | "parcel": "^1.12.4" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Document 9 | 10 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # scrollbounce 2 | [![Minified & Gzipped size](https://badgen.net/bundlephobia/minzip/scrollbounce)](https://bundlephobia.com/result?p=scrollbounce) 3 | [![npm version](https://badgen.net/npm/v/scrollbounce)](https://npmjs.org/package/scrollbounce "View this project on npm") 4 | 5 | 6 | 7 | scroll example 8 | 9 | 10 | [➡ Live demo on CodeSandbox](https://codesandbox.io/s/scrollbounce-demo-ofxn8) 11 | 12 | 13 | ## Quickstart 14 | 15 | `npm install scrollbounce` 16 | 17 | or 18 | 19 | `yarn add scrollbounce` 20 | 21 | ### 1. Give animated elements unique `data-bounce-id` attributes: 22 | 23 | ```html 24 | 29 | ``` 30 | 31 | ### 2. Init the animation: 32 | 33 | ```js 34 | import bounce from 'scrollbounce' 35 | 36 | const stopBounce = bounce() 37 | 38 | // if you want to remove the effect later: 39 | stopBounce() 40 | ``` 41 | 42 | ## Options 43 | 44 | The default effect is pretty subtle. To crank it up you can pass in an `effectMultiplier` option. 45 | 46 | ```js 47 | bounce({ effectMultiplier: 3 }) 48 | ``` 49 | 50 | ## Coming soon 51 | 52 | - [ ] Improved edge case handling 53 | - [ ] Performance optimizations 54 | - [ ] More spring customization 55 | - [ ] Support horizontal scroll 56 | 57 | ## Details 58 | 59 | - [Inspired by the "BouncyLayout" library for iOS ](https://github.com/roberthein/BouncyLayout) 60 | - This library is targeted towards touch devices and won't have any effect on desktop. 61 | -------------------------------------------------------------------------------- /dist/index.es.js: -------------------------------------------------------------------------------- 1 | import{SpringSystem as e}from"rebound";var t=function(e,t){var n=t.height;return e.top<3*n&&e.bottom>2*-n};export default function(n){void 0===n&&(n={});var o=n.effectMultiplier;void 0===o&&(o=2);var r=new e,i=Array.from(document.querySelectorAll("[data-bounce-id]")),d=window.pageYOffset,c=i.map(function(e){var t=r.createSpring();return t.addListener({onSpringUpdate:function(t){var n=t.getCurrentValue();e.style.transform="translateY("+n+"px)"}}),[e,t]}).reduce(function(e,t){return e[t[0].dataset.bounceId]=t[1],e},{}),a={},u=function(e){return e.top+e.height/2},l=function(){if(a.scrollHeight||(a.scrollHeight=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)),!(Math.abs(n)>a.viewportCoords.height)){var e=window.pageYOffset;if(!(e<=0||e>=a.scrollHeight-a.viewportCoords.height)){var n=Math.max(-40,Math.min(d-e,40)),r=document.querySelector(['[data-bounce-id="'+a.closestBounceId+'"]']),l=i.indexOf(r),s={},f=l-1,h=l+1;if(n>0)for(;;){var v=i[f];if(!v)break;var m=v.getBoundingClientRect();if(!t(m,a.viewportCoords))break;s[v.dataset.bounceId]=m,f-=1}else for(;;){var g=i[h];if(!g)break;var w=g.getBoundingClientRect();if(!t(w,a.viewportCoords))break;s[g.dataset.bounceId]=w,h+=1}var b=i.filter(function(e){return s[e.dataset.bounceId]}).map(function(e){return[e,s[e.dataset.bounceId]]});i.filter(function(e){return!s[e.dataset.bounceId]}).forEach(function(e){e.style.willChange="",c[e.dataset.bounceId].setEndValue(0)}),b.forEach(function(e){var t=e[0],r=e[1];t.style.willChange="transform";var i=c[t.dataset.bounceId],d=Math.abs(a.clientY-u(r))/a.viewportCoords.height;i.setEndValue(-n*(d*=o))}),d=e}}},s=!1,f=function(e){s||(s=window.addEventListener("scroll",l)),a.clientY=e.targetTouches[0].clientY,a.viewportCoords={height:document.documentElement.clientHeight,width:document.documentElement.clientWidth};var t=i.reduce(function(e,t,n,o){if(e.length&&e[0]!==o[n-1])return e;var r,i,d=t.getBoundingClientRect();return(r=d).top<(i=a.viewportCoords).height&&r.bottom>0&&r.left0&&(0===e.length||Math.abs(a.clientY-u(d))2*-n};return function(n){void 0===n&&(n={});var o=n.effectMultiplier;void 0===o&&(o=2);var r=new e.SpringSystem,i=Array.from(document.querySelectorAll("[data-bounce-id]")),d=window.pageYOffset,c=i.map(function(e){var t=r.createSpring();return t.addListener({onSpringUpdate:function(t){var n=t.getCurrentValue();e.style.transform="translateY("+n+"px)"}}),[e,t]}).reduce(function(e,t){return e[t[0].dataset.bounceId]=t[1],e},{}),u={},a=function(e){return e.top+e.height/2},s=function(){if(u.scrollHeight||(u.scrollHeight=Math.max(document.body.scrollHeight,document.documentElement.scrollHeight,document.body.offsetHeight,document.documentElement.offsetHeight,document.body.clientHeight,document.documentElement.clientHeight)),!(Math.abs(n)>u.viewportCoords.height)){var e=window.pageYOffset;if(!(e<=0||e>=u.scrollHeight-u.viewportCoords.height)){var n=Math.max(-40,Math.min(d-e,40)),r=document.querySelector(['[data-bounce-id="'+u.closestBounceId+'"]']),s=i.indexOf(r),l={},f=s-1,h=s+1;if(n>0)for(;;){var m=i[f];if(!m)break;var v=m.getBoundingClientRect();if(!t(v,u.viewportCoords))break;l[m.dataset.bounceId]=v,f-=1}else for(;;){var g=i[h];if(!g)break;var w=g.getBoundingClientRect();if(!t(w,u.viewportCoords))break;l[g.dataset.bounceId]=w,h+=1}var b=i.filter(function(e){return l[e.dataset.bounceId]}).map(function(e){return[e,l[e.dataset.bounceId]]});i.filter(function(e){return!l[e.dataset.bounceId]}).forEach(function(e){e.style.willChange="",c[e.dataset.bounceId].setEndValue(0)}),b.forEach(function(e){var t=e[0],r=e[1];t.style.willChange="transform";var i=c[t.dataset.bounceId],d=Math.abs(u.clientY-a(r))/u.viewportCoords.height;i.setEndValue(-n*(d*=o))}),d=e}}},l=!1,f=function(e){l||(l=window.addEventListener("scroll",s)),u.clientY=e.targetTouches[0].clientY,u.viewportCoords={height:document.documentElement.clientHeight,width:document.documentElement.clientWidth};var t=i.reduce(function(e,t,n,o){if(e.length&&e[0]!==o[n-1])return e;var r,i,d=t.getBoundingClientRect();return(r=d).top<(i=u.viewportCoords).height&&r.bottom>0&&r.left0&&(0===e.length||Math.abs(u.clientY-a(d)) { 4 | return { 5 | height: document.documentElement.clientHeight, 6 | width: document.documentElement.clientWidth 7 | } 8 | } 9 | 10 | const getScrollHeight = () => { 11 | // insane code courtesy of https://javascript.info/size-and-scroll-window 12 | return Math.max( 13 | document.body.scrollHeight, 14 | document.documentElement.scrollHeight, 15 | document.body.offsetHeight, 16 | document.documentElement.offsetHeight, 17 | document.body.clientHeight, 18 | document.documentElement.clientHeight 19 | ) 20 | } 21 | 22 | const rectInViewport = ( 23 | { top, bottom, left, right }, 24 | { width: windowWidth, height: windowHeight } 25 | ) => { 26 | return top < windowHeight && bottom > 0 && left < windowWidth && right > 0 27 | } 28 | 29 | const rectCloseToViewport = ({ top, bottom }, { height: windowHeight }) => { 30 | return top < windowHeight * 3 && bottom > -windowHeight * 2 31 | } 32 | 33 | const initScrollBounce = ({ effectMultiplier = 2 } = {}) => { 34 | const springSystem = new SpringSystem() 35 | 36 | const bounceChildren = Array.from( 37 | document.querySelectorAll("[data-bounce-id]") 38 | ) 39 | 40 | let offset = window.pageYOffset 41 | 42 | const springs = bounceChildren 43 | .map(child => { 44 | const spring = springSystem.createSpring() 45 | 46 | spring.addListener({ 47 | onSpringUpdate(_spring) { 48 | const val = _spring.getCurrentValue() 49 | child.style.transform = `translateY(${val}px)` 50 | } 51 | }) 52 | return [child, spring] 53 | }) 54 | .reduce((acc, curr) => { 55 | acc[curr[0].dataset.bounceId] = curr[1] 56 | return acc 57 | }, {}) 58 | 59 | const resetSprings = () => { 60 | Object.keys(springs).forEach(s => { 61 | springs[s].setEndValue(0) 62 | }) 63 | } 64 | 65 | let cache = {} 66 | 67 | const getCenter = ({ top, height }) => top + height / 2 68 | 69 | const onScroll = () => { 70 | const fastScroll = Math.abs(diff) > cache.viewportCoords.height 71 | if (fastScroll) return 72 | 73 | const newOffset = window.pageYOffset 74 | 75 | if (newOffset <= 0) return 76 | 77 | if (newOffset >= cache.scrollHeight - cache.viewportCoords.height) { 78 | return 79 | } 80 | 81 | const scrollDiffLimit = 40 82 | const diff = Math.max( 83 | -scrollDiffLimit, 84 | Math.min(offset - newOffset, scrollDiffLimit) 85 | ) 86 | 87 | const closestChild = document.querySelector([ 88 | `[data-bounce-id="${cache.closestBounceId}"]` 89 | ]) 90 | 91 | const closestChildIndex = bounceChildren.indexOf(closestChild) 92 | 93 | const animatedChildrenDict = {} 94 | 95 | let animatedAboveIndex = closestChildIndex - 1 96 | let animatedBelowIndex = closestChildIndex + 1 97 | 98 | const scrollDown = diff > 0 99 | 100 | if (scrollDown) { 101 | while (true) { 102 | const el = bounceChildren[animatedAboveIndex] 103 | if (!el) break 104 | const bounding = el.getBoundingClientRect() 105 | const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords) 106 | if (!isAnimated) break 107 | animatedChildrenDict[el.dataset.bounceId] = bounding 108 | animatedAboveIndex -= 1 109 | } 110 | } else { 111 | while (true) { 112 | const el = bounceChildren[animatedBelowIndex] 113 | if (!el) break 114 | const bounding = el.getBoundingClientRect() 115 | const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords) 116 | if (!isAnimated) break 117 | animatedChildrenDict[el.dataset.bounceId] = bounding 118 | animatedBelowIndex += 1 119 | } 120 | } 121 | 122 | const animatedChildren = bounceChildren 123 | .filter(c => { 124 | return animatedChildrenDict[c.dataset.bounceId] 125 | }) 126 | .map(c => { 127 | return [c, animatedChildrenDict[c.dataset.bounceId]] 128 | }) 129 | 130 | bounceChildren 131 | .filter(c => { 132 | return !animatedChildrenDict[c.dataset.bounceId] 133 | }) 134 | .forEach(c => { 135 | c.style.willChange = "" 136 | springs[c.dataset.bounceId].setEndValue(0) 137 | }) 138 | 139 | animatedChildren.forEach(([child, bounding]) => { 140 | child.style.willChange = "transform" 141 | 142 | const spring = springs[child.dataset.bounceId] 143 | 144 | let resistance = 145 | Math.abs(cache.clientY - getCenter(bounding)) / 146 | cache.viewportCoords.height 147 | 148 | resistance = resistance * effectMultiplier 149 | 150 | spring.setEndValue(-diff * resistance) 151 | }) 152 | 153 | offset = newOffset 154 | } 155 | 156 | const onTouchStart = event => { 157 | window.addEventListener("scroll", onScroll) 158 | cache.clientY = event.targetTouches[0].clientY 159 | 160 | cache.viewportCoords = getViewportCoords() 161 | cache.scrollHeight = getScrollHeight() 162 | 163 | const closestElTuple = bounceChildren.reduce((acc, curr, i, source) => { 164 | if (acc.length && acc[0] !== source[i - 1]) return acc 165 | const bounding = curr.getBoundingClientRect() 166 | if (!rectInViewport(bounding, cache.viewportCoords)) return acc 167 | if ( 168 | acc.length === 0 || 169 | Math.abs(cache.clientY - getCenter(bounding)) < 170 | Math.abs(cache.clientY - getCenter(acc[1])) 171 | ) { 172 | return [curr, bounding] 173 | } 174 | return acc 175 | }, []) 176 | 177 | cache.closestBounceId = closestElTuple[0].dataset.bounceId 178 | } 179 | 180 | const onTouchEnd = () => { 181 | window.removeEventListener("scroll", onScroll) 182 | resetSprings() 183 | cache = {} 184 | } 185 | 186 | const onTouchMove = event => { 187 | cache.clientY = event.targetTouches[0].clientY 188 | } 189 | 190 | window.addEventListener("touchstart", onTouchStart, false) 191 | window.addEventListener("touchmove", onTouchMove, false) 192 | window.addEventListener("touchend", onTouchEnd, false) 193 | 194 | return () => { 195 | window.removeEventListener("touchstart", onTouchStart, false) 196 | window.removeEventListener("touchmove", onTouchMove, false) 197 | window.removeEventListener("touchend", onTouchEnd, false) 198 | } 199 | } 200 | 201 | export default initScrollBounce 202 | -------------------------------------------------------------------------------- /dist/index.es.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.es.js","sources":["../src/index.js"],"sourcesContent":["import { SpringSystem } from \"rebound\"\n\nconst getViewportCoords = () => {\n return {\n height: document.documentElement.clientHeight,\n width: document.documentElement.clientWidth\n }\n}\n\nconst getScrollHeight = () => {\n // insane code courtesy of https://javascript.info/size-and-scroll-window\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight\n )\n}\n\nconst rectInViewport = (\n { top, bottom, left, right },\n { width: windowWidth, height: windowHeight }\n) => {\n return top < windowHeight && bottom > 0 && left < windowWidth && right > 0\n}\n\nconst rectCloseToViewport = ({ top, bottom }, { height: windowHeight }) => {\n return top < windowHeight * 3 && bottom > -windowHeight * 2\n}\n\nconst initScrollBounce = ({ effectMultiplier = 2 } = {}) => {\n const springSystem = new SpringSystem()\n\n const bounceChildren = Array.from(\n document.querySelectorAll(\"[data-bounce-id]\")\n )\n\n let offset = window.pageYOffset\n\n const springs = bounceChildren\n .map(child => {\n const spring = springSystem.createSpring()\n\n spring.addListener({\n onSpringUpdate(_spring) {\n const val = _spring.getCurrentValue()\n child.style.transform = `translateY(${val}px)`\n }\n })\n return [child, spring]\n })\n .reduce((acc, curr) => {\n acc[curr[0].dataset.bounceId] = curr[1]\n return acc\n }, {})\n\n const resetSprings = () => {\n Object.keys(springs).forEach(s => {\n springs[s].setEndValue(0)\n })\n }\n\n let cache = {}\n\n const getCenter = bounding => {\n return bounding.top + bounding.height / 2\n }\n\n const onScroll = () => {\n if (!cache.scrollHeight) cache.scrollHeight = getScrollHeight()\n\n const fastScroll = Math.abs(diff) > cache.viewportCoords.height\n if (fastScroll) return\n\n const newOffset = window.pageYOffset\n\n if (newOffset <= 0) return\n\n if (newOffset >= cache.scrollHeight - cache.viewportCoords.height) {\n return\n }\n\n const scrollDiffLimit = 40\n const diff = Math.max(\n -scrollDiffLimit,\n Math.min(offset - newOffset, scrollDiffLimit)\n )\n\n const closestChild = document.querySelector([\n `[data-bounce-id=\"${cache.closestBounceId}\"]`\n ])\n\n const closestChildIndex = bounceChildren.indexOf(closestChild)\n\n const animatedChildrenDict = {}\n\n let animatedAboveIndex = closestChildIndex - 1\n let animatedBelowIndex = closestChildIndex + 1\n\n const scrollDown = diff > 0\n\n if (scrollDown) {\n while (true) {\n const el = bounceChildren[animatedAboveIndex]\n if (!el) break\n const bounding = el.getBoundingClientRect()\n const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords)\n if (!isAnimated) break\n animatedChildrenDict[el.dataset.bounceId] = bounding\n animatedAboveIndex -= 1\n }\n } else {\n while (true) {\n const el = bounceChildren[animatedBelowIndex]\n if (!el) break\n const bounding = el.getBoundingClientRect()\n const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords)\n if (!isAnimated) break\n animatedChildrenDict[el.dataset.bounceId] = bounding\n animatedBelowIndex += 1\n }\n }\n\n const animatedChildren = bounceChildren\n .filter(c => {\n return animatedChildrenDict[c.dataset.bounceId]\n })\n .map(c => {\n return [c, animatedChildrenDict[c.dataset.bounceId]]\n })\n\n bounceChildren\n .filter(c => {\n return !animatedChildrenDict[c.dataset.bounceId]\n })\n .forEach(c => {\n c.style.willChange = \"\"\n springs[c.dataset.bounceId].setEndValue(0)\n })\n\n animatedChildren.forEach(([child, bounding]) => {\n child.style.willChange = \"transform\"\n\n const spring = springs[child.dataset.bounceId]\n\n let resistance =\n Math.abs(cache.clientY - getCenter(bounding)) /\n cache.viewportCoords.height\n\n resistance = resistance * effectMultiplier\n\n spring.setEndValue(-diff * resistance)\n })\n\n offset = newOffset\n }\n\n let scrollListener = false\n\n const onTouchStart = event => {\n if (!scrollListener)\n scrollListener = window.addEventListener(\"scroll\", onScroll)\n cache.clientY = event.targetTouches[0].clientY\n\n cache.viewportCoords = getViewportCoords()\n\n const closestElTuple = bounceChildren.reduce((acc, curr, i, source) => {\n if (acc.length && acc[0] !== source[i - 1]) return acc\n const bounding = curr.getBoundingClientRect()\n if (!rectInViewport(bounding, cache.viewportCoords)) return acc\n if (\n acc.length === 0 ||\n Math.abs(cache.clientY - getCenter(bounding)) <\n Math.abs(cache.clientY - getCenter(acc[1]))\n ) {\n return [curr, bounding]\n }\n return acc\n }, [])\n\n cache.closestBounceId = closestElTuple[0].dataset.bounceId\n }\n\n const onTouchEnd = () => {\n window.removeEventListener(\"scroll\", onScroll)\n resetSprings()\n cache = {}\n }\n\n const onTouchMove = event => {\n cache.clientY = event.targetTouches[0].clientY\n }\n\n window.addEventListener(\"touchstart\", onTouchStart, false)\n window.addEventListener(\"touchmove\", onTouchMove, false)\n window.addEventListener(\"touchend\", onTouchEnd, false)\n\n return () => {\n window.removeEventListener(\"touchstart\", onTouchStart, false)\n window.removeEventListener(\"touchmove\", onTouchMove, false)\n window.removeEventListener(\"touchend\", onTouchEnd, false)\n }\n}\n\nexport default initScrollBounce\n"],"names":["const","rectCloseToViewport","ref","ref$1","windowHeight","springSystem","SpringSystem","bounceChildren","Array","from","document","querySelectorAll","offset","window","pageYOffset","springs","map","child","spring","createSpring","addListener","onSpringUpdate","_spring","val","getCurrentValue","style","transform","reduce","acc","curr","dataset","bounceId","cache","getCenter","bounding","top","height","onScroll","scrollHeight","Math","max","body","documentElement","offsetHeight","clientHeight","abs","diff","viewportCoords","newOffset","min","closestChild","querySelector","closestChildIndex","indexOf","animatedChildrenDict","animatedAboveIndex","animatedBelowIndex","el","getBoundingClientRect","animatedChildren","filter","c","forEach","willChange","setEndValue","resistance","clientY","effectMultiplier","scrollListener","onTouchStart","event","addEventListener","targetTouches","width","clientWidth","closestElTuple","i","source","length","closestBounceId","onTouchEnd","removeEventListener","Object","keys","s","onTouchMove"],"mappings":"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"} -------------------------------------------------------------------------------- /dist/index.umd.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"index.umd.js","sources":["../src/index.js"],"sourcesContent":["import { SpringSystem } from \"rebound\"\n\nconst getViewportCoords = () => {\n return {\n height: document.documentElement.clientHeight,\n width: document.documentElement.clientWidth\n }\n}\n\nconst getScrollHeight = () => {\n // insane code courtesy of https://javascript.info/size-and-scroll-window\n return Math.max(\n document.body.scrollHeight,\n document.documentElement.scrollHeight,\n document.body.offsetHeight,\n document.documentElement.offsetHeight,\n document.body.clientHeight,\n document.documentElement.clientHeight\n )\n}\n\nconst rectInViewport = (\n { top, bottom, left, right },\n { width: windowWidth, height: windowHeight }\n) => {\n return top < windowHeight && bottom > 0 && left < windowWidth && right > 0\n}\n\nconst rectCloseToViewport = ({ top, bottom }, { height: windowHeight }) => {\n return top < windowHeight * 3 && bottom > -windowHeight * 2\n}\n\nconst initScrollBounce = ({ effectMultiplier = 2 } = {}) => {\n const springSystem = new SpringSystem()\n\n const bounceChildren = Array.from(\n document.querySelectorAll(\"[data-bounce-id]\")\n )\n\n let offset = window.pageYOffset\n\n const springs = bounceChildren\n .map(child => {\n const spring = springSystem.createSpring()\n\n spring.addListener({\n onSpringUpdate(_spring) {\n const val = _spring.getCurrentValue()\n child.style.transform = `translateY(${val}px)`\n }\n })\n return [child, spring]\n })\n .reduce((acc, curr) => {\n acc[curr[0].dataset.bounceId] = curr[1]\n return acc\n }, {})\n\n const resetSprings = () => {\n Object.keys(springs).forEach(s => {\n springs[s].setEndValue(0)\n })\n }\n\n let cache = {}\n\n const getCenter = bounding => {\n return bounding.top + bounding.height / 2\n }\n\n const onScroll = () => {\n if (!cache.scrollHeight) cache.scrollHeight = getScrollHeight()\n\n const fastScroll = Math.abs(diff) > cache.viewportCoords.height\n if (fastScroll) return\n\n const newOffset = window.pageYOffset\n\n if (newOffset <= 0) return\n\n if (newOffset >= cache.scrollHeight - cache.viewportCoords.height) {\n return\n }\n\n const scrollDiffLimit = 40\n const diff = Math.max(\n -scrollDiffLimit,\n Math.min(offset - newOffset, scrollDiffLimit)\n )\n\n const closestChild = document.querySelector([\n `[data-bounce-id=\"${cache.closestBounceId}\"]`\n ])\n\n const closestChildIndex = bounceChildren.indexOf(closestChild)\n\n const animatedChildrenDict = {}\n\n let animatedAboveIndex = closestChildIndex - 1\n let animatedBelowIndex = closestChildIndex + 1\n\n const scrollDown = diff > 0\n\n if (scrollDown) {\n while (true) {\n const el = bounceChildren[animatedAboveIndex]\n if (!el) break\n const bounding = el.getBoundingClientRect()\n const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords)\n if (!isAnimated) break\n animatedChildrenDict[el.dataset.bounceId] = bounding\n animatedAboveIndex -= 1\n }\n } else {\n while (true) {\n const el = bounceChildren[animatedBelowIndex]\n if (!el) break\n const bounding = el.getBoundingClientRect()\n const isAnimated = rectCloseToViewport(bounding, cache.viewportCoords)\n if (!isAnimated) break\n animatedChildrenDict[el.dataset.bounceId] = bounding\n animatedBelowIndex += 1\n }\n }\n\n const animatedChildren = bounceChildren\n .filter(c => {\n return animatedChildrenDict[c.dataset.bounceId]\n })\n .map(c => {\n return [c, animatedChildrenDict[c.dataset.bounceId]]\n })\n\n bounceChildren\n .filter(c => {\n return !animatedChildrenDict[c.dataset.bounceId]\n })\n .forEach(c => {\n c.style.willChange = \"\"\n springs[c.dataset.bounceId].setEndValue(0)\n })\n\n animatedChildren.forEach(([child, bounding]) => {\n child.style.willChange = \"transform\"\n\n const spring = springs[child.dataset.bounceId]\n\n let resistance =\n Math.abs(cache.clientY - getCenter(bounding)) /\n cache.viewportCoords.height\n\n resistance = resistance * effectMultiplier\n\n spring.setEndValue(-diff * resistance)\n })\n\n offset = newOffset\n }\n\n let scrollListener = false\n\n const onTouchStart = event => {\n if (!scrollListener)\n scrollListener = window.addEventListener(\"scroll\", onScroll)\n cache.clientY = event.targetTouches[0].clientY\n\n cache.viewportCoords = getViewportCoords()\n\n const closestElTuple = bounceChildren.reduce((acc, curr, i, source) => {\n if (acc.length && acc[0] !== source[i - 1]) return acc\n const bounding = curr.getBoundingClientRect()\n if (!rectInViewport(bounding, cache.viewportCoords)) return acc\n if (\n acc.length === 0 ||\n Math.abs(cache.clientY - getCenter(bounding)) <\n Math.abs(cache.clientY - getCenter(acc[1]))\n ) {\n return [curr, bounding]\n }\n return acc\n }, [])\n\n cache.closestBounceId = closestElTuple[0].dataset.bounceId\n }\n\n const onTouchEnd = () => {\n window.removeEventListener(\"scroll\", onScroll)\n resetSprings()\n cache = {}\n }\n\n const onTouchMove = event => {\n cache.clientY = event.targetTouches[0].clientY\n }\n\n window.addEventListener(\"touchstart\", onTouchStart, false)\n window.addEventListener(\"touchmove\", onTouchMove, false)\n window.addEventListener(\"touchend\", onTouchEnd, false)\n\n return () => {\n window.removeEventListener(\"touchstart\", onTouchStart, false)\n window.removeEventListener(\"touchmove\", onTouchMove, false)\n window.removeEventListener(\"touchend\", onTouchEnd, false)\n }\n}\n\nexport default initScrollBounce\n"],"names":["const","rectCloseToViewport","ref","ref$1","windowHeight","springSystem","SpringSystem","bounceChildren","Array","from","document","querySelectorAll","offset","window","pageYOffset","springs","map","child","spring","createSpring","addListener","onSpringUpdate","_spring","val","getCurrentValue","style","transform","reduce","acc","curr","dataset","bounceId","cache","getCenter","bounding","top","height","onScroll","scrollHeight","Math","max","body","documentElement","offsetHeight","clientHeight","abs","diff","viewportCoords","newOffset","min","closestChild","querySelector","closestChildIndex","indexOf","animatedChildrenDict","animatedAboveIndex","animatedBelowIndex","el","getBoundingClientRect","animatedChildren","filter","c","forEach","willChange","setEndValue","resistance","clientY","effectMultiplier","scrollListener","onTouchStart","event","addEventListener","targetTouches","width","clientWidth","closestElTuple","i","source","length","closestBounceId","onTouchEnd","removeEventListener","Object","keys","s","onTouchMove"],"mappings":"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"} -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | // modules are defined as an array 2 | // [ module function, map of requires ] 3 | // 4 | // map of requires is short require name -> numeric require 5 | // 6 | // anything defined in a previous bundle is accessed via the 7 | // orig method which is the require for previous bundles 8 | parcelRequire = (function (modules, cache, entry, globalName) { 9 | // Save the require from previous bundle to this closure if any 10 | var previousRequire = typeof parcelRequire === 'function' && parcelRequire; 11 | var nodeRequire = typeof require === 'function' && require; 12 | 13 | function newRequire(name, jumped) { 14 | if (!cache[name]) { 15 | if (!modules[name]) { 16 | // if we cannot find the module within our internal map or 17 | // cache jump to the current global require ie. the last bundle 18 | // that was added to the page. 19 | var currentRequire = typeof parcelRequire === 'function' && parcelRequire; 20 | if (!jumped && currentRequire) { 21 | return currentRequire(name, true); 22 | } 23 | 24 | // If there are other bundles on this page the require from the 25 | // previous one is saved to 'previousRequire'. Repeat this as 26 | // many times as there are bundles until the module is found or 27 | // we exhaust the require chain. 28 | if (previousRequire) { 29 | return previousRequire(name, true); 30 | } 31 | 32 | // Try the node require function if it exists. 33 | if (nodeRequire && typeof name === 'string') { 34 | return nodeRequire(name); 35 | } 36 | 37 | var err = new Error('Cannot find module \'' + name + '\''); 38 | err.code = 'MODULE_NOT_FOUND'; 39 | throw err; 40 | } 41 | 42 | localRequire.resolve = resolve; 43 | localRequire.cache = {}; 44 | 45 | var module = cache[name] = new newRequire.Module(name); 46 | 47 | modules[name][0].call(module.exports, localRequire, module, module.exports, this); 48 | } 49 | 50 | return cache[name].exports; 51 | 52 | function localRequire(x){ 53 | return newRequire(localRequire.resolve(x)); 54 | } 55 | 56 | function resolve(x){ 57 | return modules[name][1][x] || x; 58 | } 59 | } 60 | 61 | function Module(moduleName) { 62 | this.id = moduleName; 63 | this.bundle = newRequire; 64 | this.exports = {}; 65 | } 66 | 67 | newRequire.isParcelRequire = true; 68 | newRequire.Module = Module; 69 | newRequire.modules = modules; 70 | newRequire.cache = cache; 71 | newRequire.parent = previousRequire; 72 | newRequire.register = function (id, exports) { 73 | modules[id] = [function (require, module) { 74 | module.exports = exports; 75 | }, {}]; 76 | }; 77 | 78 | var error; 79 | for (var i = 0; i < entry.length; i++) { 80 | try { 81 | newRequire(entry[i]); 82 | } catch (e) { 83 | // Save first error but execute all entries 84 | if (!error) { 85 | error = e; 86 | } 87 | } 88 | } 89 | 90 | if (entry.length) { 91 | // Expose entry point to Node, AMD or browser globals 92 | // Based on https://github.com/ForbesLindesay/umd/blob/master/template.js 93 | var mainExports = newRequire(entry[entry.length - 1]); 94 | 95 | // CommonJS 96 | if (typeof exports === "object" && typeof module !== "undefined") { 97 | module.exports = mainExports; 98 | 99 | // RequireJS 100 | } else if (typeof define === "function" && define.amd) { 101 | define(function () { 102 | return mainExports; 103 | }); 104 | 105 | //