├── .babelrc ├── .circleci └── config.yml ├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── .babelrc ├── index.js └── index.test.js ├── tools ├── TestComponent.js ├── babel-preset.js ├── build.js └── jest-setup.js └── types ├── index.d.ts └── tsconfig.json /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "./tools/babel-preset" ] 3 | } 4 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | node: circleci/node@4.7.0 5 | 6 | jobs: 7 | build: 8 | docker: 9 | # specify the version you desire here 10 | - image: cimg/node:17.2.0 11 | 12 | # Specify service dependencies here if necessary 13 | # CircleCI maintains a library of pre-built images 14 | # documented at https://circleci.com/docs/2.0/circleci-images/ 15 | # - image: circleci/mongo:3.4.4 16 | 17 | working_directory: ~/repo 18 | 19 | steps: 20 | - checkout 21 | 22 | # Download and cache dependencies 23 | - restore_cache: 24 | keys: 25 | - v1-dependencies-{{ checksum "package.json" }} 26 | # fallback to using the latest cache if no exact match is found 27 | - v1-dependencies- 28 | 29 | - run: npm install 30 | 31 | - save_cache: 32 | paths: 33 | - node_modules 34 | key: v1-dependencies-{{ checksum "package.json" }} 35 | 36 | - run: ./node_modules/.bin/greenkeeper-lockfile-update 37 | - run: npm run build 38 | 39 | # run tests! 40 | - run: npm run test 41 | 42 | # send code coverage info to coveralls 43 | - run: cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 44 | 45 | - run: ./node_modules/.bin/greenkeeper-lockfile-upload 46 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: ryanhefner # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ryanhefner # Replace with a single Patreon username 5 | open_collective: ryanhefner # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with a single custom sponsorship URL 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | coverage 3 | es 4 | node_modules 5 | umd 6 | /*.js 7 | !rollup.config.js 8 | *.sublime-project 9 | *.sublime-workspace 10 | yarn-error.log 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | coverage 2 | tools 3 | rollup.config.js 4 | **/*.test.js 5 | *.sublime-project 6 | *.sublime-workspace 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 - 2018 Ryan Hefner (https://www.ryanhefner.com) 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 | # 📜 react-scroll-trigger 2 | 3 | [![npm](https://img.shields.io/npm/v/react-scroll-trigger?style=flat-square)](https://www.pkgstats.com/pkg:react-scroll-trigger) 4 | [![NPM](https://img.shields.io/npm/l/react-scroll-trigger?style=flat-square)](https://www.pkgstats.com/pkg:react-scroll-trigger) 5 | [![npm](https://img.shields.io/npm/dt/react-scroll-trigger?style=flat-square)](https://www.pkgstats.com/pkg:react-scroll-trigger) 6 | [![Coveralls github](https://img.shields.io/coveralls/github/ryanhefner/react-scroll-trigger?style=flat-square)](https://coveralls.io/github/ryanhefner/react-scroll-trigger) 7 | ![CircleCI](https://img.shields.io/circleci/build/github/ryanhefner/react-scroll-trigger?style=flat-square) 8 | ![Snyk Vulnerabilities for GitHub Repo](https://img.shields.io/snyk/vulnerabilities/github/ryanhefner/react-scroll-trigger?style=flat-square) 9 | 10 | 11 | React component that monitors `scroll` events to trigger callbacks when it enters, 12 | exits and progresses through the viewport. All callback include the `progress` and 13 | `velocity` of the scrolling, in the event you want to manipulate stuff based on 14 | those values. 15 | 16 | ## Install 17 | 18 | Via [npm](https://npmjs.com/package/react-scroll-trigger) 19 | 20 | ```sh 21 | npm install react-scroll-trigger 22 | ``` 23 | 24 | Via [Yarn](http://yarn.fyi/react-scroll-trigger) 25 | 26 | ```sh 27 | yarn add react-scroll-trigger 28 | ``` 29 | 30 | ## How to use 31 | 32 | ```js 33 | import ScrollTrigger from 'react-scroll-trigger'; 34 | 35 | ... 36 | 37 | onEnterViewport() { 38 | this.setState({ 39 | visible: true, 40 | }); 41 | } 42 | 43 | onExitViewport() { 44 | this.setState({ 45 | visible: false, 46 | }); 47 | } 48 | 49 | render() { 50 | const { 51 | visible, 52 | } = this.state; 53 | 54 | return ( 55 | 56 |
57 | 58 | ); 59 | } 60 | ``` 61 | 62 | The `ScrollTrigger` is intended to be used as a composable element, allowing you 63 | to either use it standalone within a page (ie. no children). 64 | 65 | ```js 66 | 67 | ``` 68 | 69 | Or, pass in children to receive events and `progress` based on the dimensions of 70 | those elements within the DOM. 71 | 72 | ```js 73 | 74 | 75 | [...list items...] 76 | 77 | 78 | ``` 79 | 80 | The beauty of this component is its flexibility. I’ve used it to trigger 81 | AJAX requests based on either the `onEnter` or `progress` of the component within 82 | the viewport. Or, as a way to control animations or other transitions that you 83 | would like to either trigger when visible in the viewport or based on the exact 84 | `progress` of that element as it transitions through the viewport. 85 | 86 | ### Properties 87 | 88 | Below are the properties that can be defined on a `` instance. 89 | In addition to these properties, all other standard React properites like `className`, 90 | `key`, etc. can be passed in as well and will be applied to the `
` that will 91 | be rendered by the `ScrollTrigger`. 92 | 93 | * `component:Element | String` - React component or HTMLElement to render as the wrapper for the `ScrollTrigger` (Default: `div`) 94 | * `containerRef:Object | String` - DOM element instance or string to use for query selecting DOM element. (Default: `document.documentElement`) 95 | * `throttleResize:Number` - Delay to throttle `resize` calls in milliseconds (Default: `100`) 96 | * `throttleScroll:Number` - Delay to throttle `scroll` calls in milliseconds (Default: `100`) 97 | * `triggerOnLoad:Boolean` - Whether or not to trigger the `onEnter` callback on mount (Default: `true`) 98 | * `onEnter:Function` - Called when the component enters the viewport `({progress, velocity}, ref) => {}` 99 | * `onExit:Function` - Called when the component exits the viewport `({progress, velocity}, ref) => {}` 100 | * `onProgress:Function` - Called while the component progresses through the viewport `({progress, velocity}, ref) => {}` 101 | 102 | ## License 103 | 104 | [MIT](LICENSE) © [Ryan Hefner](https://www.ryanhefner.com) 105 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-scroll-trigger", 3 | "version": "0.6.14", 4 | "license": "MIT", 5 | "description": "React component tied to scroll events with callbacks for enter, exit and progress while scrolling through the viewport.", 6 | "repository": "ryanhefner/react-scroll-trigger", 7 | "keywords": [ 8 | "react", 9 | "react-component", 10 | "scroll", 11 | "trigger" 12 | ], 13 | "author": "Ryan Hefner (https://www.ryanhefner.com)", 14 | "bugs": { 15 | "url": "https://github.com/ryanhefner/react-contentful/issues" 16 | }, 17 | "homepage": "https://github.com/ryanhefner/react-contentful#readme", 18 | "funding": { 19 | "type": "github", 20 | "url": "https://github.com/sponsors/ryanhefner" 21 | }, 22 | "files": [ 23 | "es", 24 | "index.js", 25 | "src", 26 | "types", 27 | "umd" 28 | ], 29 | "directories": { 30 | "lib": "/src" 31 | }, 32 | "main": "index.js", 33 | "module": "es/index.js", 34 | "jsnext:main": "src/index.js", 35 | "types": "types", 36 | "scripts": { 37 | "clean": "rimraf index.js es umd", 38 | "prebuild": "npm run clean", 39 | "build": "node ./tools/build.js", 40 | "watch": "babel ./src -d . --ignore __tests__,**/*.test.js --watch", 41 | "prepare": "npm run build", 42 | "prepublishOnly": "node ./tools/build.js", 43 | "push-release": "git push origin master && git push --tags", 44 | "dtslint": "dtslint types", 45 | "test": "jest" 46 | }, 47 | "peerDependencies": { 48 | "react": ">=15", 49 | "react-dom": ">=15" 50 | }, 51 | "dependencies": { 52 | "@types/react": "^16.14.23", 53 | "@types/react-dom": "^16.9.14", 54 | "clean-react-props": "^0.4.0", 55 | "lodash.throttle": "^4.1.1", 56 | "prop-types": "^15.8.1" 57 | }, 58 | "devDependencies": { 59 | "@babel/cli": "^7.12.0", 60 | "@babel/core": "^7.12.0", 61 | "@babel/plugin-external-helpers": "^7.10.4", 62 | "@babel/plugin-proposal-object-rest-spread": "^7.11.0", 63 | "@babel/preset-env": "^7.12.0", 64 | "@babel/preset-react": "^7.10.4", 65 | "babel-core": "^7.0.0-0", 66 | "babel-jest": "^26.5.2", 67 | "babel-plugin-dev-expression": "^0.2.2", 68 | "babel-plugin-transform-react-remove-prop-types": "^0.4.24", 69 | "coveralls": "^3.1.0", 70 | "dtslint": "^4.0.4", 71 | "enzyme": "^3.11.0", 72 | "enzyme-adapter-react-16": "^1.15.5", 73 | "greenkeeper-lockfile": "^1.15.1", 74 | "gzip-size": "^6.0.0", 75 | "jest": "^26.5.3", 76 | "jest-enzyme": "^7.1.2", 77 | "jsdom": "^16.4.0", 78 | "pretty-bytes": "^5.4.1", 79 | "react": "^16.14.0", 80 | "react-dom": "^16.14.0", 81 | "react-test-renderer": "^16.14.0", 82 | "regenerator-runtime": "^0.13.7", 83 | "rimraf": "^3.0.2", 84 | "rollup": "^2.30.0", 85 | "rollup-plugin-babel": "^4.4.0", 86 | "rollup-plugin-commonjs": "^10.1.0", 87 | "rollup-plugin-json": "^4.0.0", 88 | "rollup-plugin-node-resolve": "^5.2.0", 89 | "rollup-plugin-terser": "^7.0.2", 90 | "typescript": "^4.0.3" 91 | }, 92 | "jest": { 93 | "collectCoverage": true, 94 | "collectCoverageFrom": [ 95 | "src/**/*.js", 96 | "!src/**/*.test.js" 97 | ], 98 | "setupFiles": [ 99 | "/tools/jest-setup.js" 100 | ], 101 | "setupFilesAfterEnv": [ 102 | "./node_modules/jest-enzyme/lib/index.js" 103 | ], 104 | "testURL": "http://localhost" 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel'; 2 | import commonjs from 'rollup-plugin-commonjs'; 3 | import json from 'rollup-plugin-json'; 4 | import resolve from 'rollup-plugin-node-resolve'; 5 | import { terser } from 'rollup-plugin-terser'; 6 | import pkg from './package.json'; 7 | 8 | const config = { 9 | input: 'src/index.js', 10 | output: { 11 | name: 'react-scroll-trigger', 12 | file: './index.js', 13 | format: 'umd', 14 | globals: { 15 | 'react': 'React', 16 | 'react-dom': 'ReactDOM', 17 | }, 18 | banner: `/*! ${pkg.name} v${pkg.version} | (c) ${new Date().getFullYear()} Ryan Hefner | ${pkg.license} License | https://github.com/${pkg.repository} !*/`, 19 | footer: '/* follow me on Twitter! @ryanhefner */', 20 | }, 21 | external: [ 22 | 'react', 23 | 'react-dom', 24 | ], 25 | plugins: [ 26 | babel({ 27 | exclude: 'node_modules/**', 28 | externalHelpers: process.env.BABEL_ENV === 'umd', 29 | }), 30 | resolve(), 31 | commonjs({ 32 | include: /node_modules/, 33 | }), 34 | json(), 35 | ], 36 | }; 37 | 38 | if (process.env.NODE_ENV === 'production') { 39 | config.plugins.push(terser()); 40 | } 41 | 42 | export default config; 43 | -------------------------------------------------------------------------------- /src/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ "../tools/babel-preset.js" ] 3 | } 4 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ReactDOM from 'react-dom'; 4 | import throttle from 'lodash.throttle'; 5 | import cleanProps from 'clean-react-props'; 6 | 7 | class ScrollTrigger extends Component { 8 | constructor(props) { 9 | super(props); 10 | 11 | this.onScrollThrottled = throttle(this.onScroll.bind(this), props.throttleScroll, { 12 | trailing: false, 13 | }); 14 | 15 | this.onResizeThrottled = throttle(this.onResize.bind(this), props.throttleResize, { 16 | trailing: false, 17 | }); 18 | 19 | this.state = { 20 | inViewport: false, 21 | progress: 0, 22 | lastScrollPosition: null, 23 | lastScrollTime: null, 24 | }; 25 | } 26 | 27 | componentDidMount() { 28 | addEventListener('resize', this.onResizeThrottled); 29 | addEventListener('scroll', this.onScrollThrottled); 30 | 31 | if (this.props.triggerOnLoad) { 32 | this.checkStatus(); 33 | } 34 | } 35 | 36 | componentDidUpdate(prevProps, prevState) { 37 | if (prevProps.throttleScroll !== this.props.throttleScroll) { 38 | removeEventListener('scroll', this.onScrollThrottled); 39 | this.onScrollThrottled = throttle(this.onScroll.bind(this), this.props.throttleScroll, { 40 | trailing: false, 41 | }); 42 | addEventListener('scroll', this.onScrollThrottled); 43 | } 44 | 45 | if (prevProps.throttleResize !== this.props.throttleResize) { 46 | removeEventListener('resize', this.onResizeThrottled); 47 | this.onResizeThrottled = throttle(this.onResize.bind(this), this.props.throttleResize, { 48 | trailing: false, 49 | }); 50 | addEventListener('resize', this.onResizeThrottled); 51 | } 52 | } 53 | 54 | componentWillUnmount() { 55 | removeEventListener('resize', this.onResizeThrottled); 56 | removeEventListener('scroll', this.onScrollThrottled); 57 | } 58 | 59 | onResize() { 60 | this.checkStatus(); 61 | } 62 | 63 | onScroll() { 64 | this.checkStatus(); 65 | } 66 | 67 | checkStatus() { 68 | const { 69 | containerRef, 70 | onEnter, 71 | onExit, 72 | onProgress, 73 | } = this.props; 74 | 75 | const { 76 | lastScrollPosition, 77 | lastScrollTime, 78 | } = this.state; 79 | 80 | const element = ReactDOM.findDOMNode(this.element); 81 | const elementRect = element.getBoundingClientRect(); 82 | const viewportStart = 0; 83 | const scrollingElement = typeof containerRef === 'string' 84 | ? document.querySelector(containerRef) 85 | : containerRef; 86 | const viewportEnd = containerRef === document.documentElement 87 | ? Math.max(containerRef.clientHeight, window.innerHeight || 0) 88 | : scrollingElement.clientHeight; 89 | const inViewport = elementRect.top <= viewportEnd && elementRect.bottom >= viewportStart; 90 | 91 | const position = window.scrollY; 92 | const velocity = lastScrollPosition && lastScrollTime 93 | ? Math.abs((lastScrollPosition - position) / (lastScrollTime - Date.now())) 94 | : null; 95 | 96 | if (inViewport) { 97 | const progress = Math.max(0, Math.min(1, 1 - (elementRect.bottom / (viewportEnd + elementRect.height)))); 98 | 99 | if (!this.state.inViewport) { 100 | this.setState({ 101 | inViewport, 102 | }); 103 | 104 | onEnter({ 105 | progress, 106 | velocity, 107 | }, this); 108 | } 109 | 110 | onProgress({ 111 | progress, 112 | velocity, 113 | }, this); 114 | 115 | this.setState({ 116 | lastScrollPosition: position, 117 | lastScrollTime: Date.now(), 118 | }); 119 | return; 120 | } 121 | 122 | if (this.state.inViewport) { 123 | const progress = elementRect.top <= viewportEnd ? 1 : 0; 124 | 125 | this.setState({ 126 | lastScrollPosition: position, 127 | lastScrollTime: Date.now(), 128 | inViewport, 129 | progress, 130 | }); 131 | 132 | onProgress({ 133 | progress, 134 | velocity, 135 | }, this); 136 | 137 | onExit({ 138 | progress, 139 | velocity, 140 | }, this); 141 | } 142 | } 143 | 144 | render() { 145 | const { 146 | children, 147 | component, 148 | } = this.props; 149 | 150 | const elementMethod = React.isValidElement(component) 151 | ? 'cloneElement' 152 | : 'createElement'; 153 | 154 | return React[elementMethod](component, { 155 | ...cleanProps(this.props, ['onProgress']), 156 | ref: (element) => { 157 | this.element = element; 158 | }, 159 | }, 160 | children, 161 | ); 162 | } 163 | } 164 | 165 | ScrollTrigger.propTypes = { 166 | component: PropTypes.oneOfType([ 167 | PropTypes.element, 168 | PropTypes.node, 169 | ]), 170 | containerRef: PropTypes.oneOfType([ 171 | PropTypes.object, 172 | PropTypes.string, 173 | ]), 174 | throttleResize: PropTypes.number, 175 | throttleScroll: PropTypes.number, 176 | triggerOnLoad: PropTypes.bool, 177 | onEnter: PropTypes.func, 178 | onExit: PropTypes.func, 179 | onProgress: PropTypes.func, 180 | }; 181 | 182 | ScrollTrigger.defaultProps = { 183 | component: 'div', 184 | containerRef: (typeof document !== 'undefined') ? document.documentElement : 'html', 185 | throttleResize: 100, 186 | throttleScroll: 100, 187 | triggerOnLoad: true, 188 | onEnter: () => {}, 189 | onExit: () => {}, 190 | onProgress: () => {}, 191 | }; 192 | 193 | export default ScrollTrigger; 194 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Enzyme, { mount, shallow } from 'enzyme'; 3 | import Adapter from 'enzyme-adapter-react-16'; 4 | import throttle from 'lodash.throttle'; 5 | import ScrollTrigger from './index'; 6 | import TestComponent from '../tools/TestComponent'; 7 | 8 | Enzyme.configure({ 9 | adapter: new Adapter(), 10 | }); 11 | 12 | let component; 13 | 14 | describe('', () => { 15 | beforeEach(() => { 16 | window.Element.prototype.getBoundingClientRect = () => { 17 | return { 18 | left: 0, 19 | top: 0, 20 | right: 0, 21 | bottom: 0, 22 | x: 0, 23 | y: 0, 24 | width: 0, 25 | height: 0, 26 | }; 27 | }; 28 | }); 29 | 30 | test('ScrollTrigger should render a div', () => { 31 | component = mount(, { 32 | attachTo: document.getElementById('root'), 33 | }); 34 | expect(component.contains(
)).toBe(true); 35 | component.unmount(); 36 | }); 37 | 38 | test('ScrollTrigger should render a specified component', () => { 39 | component = mount(, { 40 | attachTo: document.getElementById('root'), 41 | }); 42 | expect(component.contains(
)).toBe(true); 43 | component.unmount(); 44 | }); 45 | 46 | test('ScrollTrigger should render an element passed into component', () => { 47 | component = mount(} />, { 48 | attachTo: document.getElementById('root'), 49 | }); 50 | expect(component.contains(
)).toBe(true); 51 | component.unmount(); 52 | }); 53 | 54 | test('ScrollTrigger should render an element passed into component with children', () => { 55 | component = mount(}>
, { 56 | attachTo: document.getElementById('root'), 57 | }); 58 | expect(component.contains(
)).toBe(true); 59 | component.unmount(); 60 | }); 61 | 62 | test('ScrollTrigger fires onEnter when loaded', () => { 63 | let onEnterFired = false; 64 | const onEnter = () => { 65 | onEnterFired = true; 66 | }; 67 | component = mount(, { 68 | attachTo: document.getElementById('root'), 69 | }); 70 | expect(onEnterFired).toBe(true); 71 | component.unmount(); 72 | }); 73 | 74 | test('ScrollTrigger _does not_ fire onEnter when loaded with triggerOnLoad = false', () => { 75 | let onEnterFired = false; 76 | const onEnter = () => { 77 | onEnterFired = true; 78 | }; 79 | component = mount(, { 80 | attachTo: document.getElementById('root'), 81 | }); 82 | expect(onEnterFired).toBe(false); 83 | component.unmount(); 84 | }); 85 | 86 | test('ScrollTrigger _does not_ fire onEnter when loaded outside of viewport', () => { 87 | window.resizeTo(1024, 768); 88 | window.Element.prototype.getBoundingClientRect = () => { 89 | return { 90 | left: 0, 91 | top: 1572, 92 | right: 0, 93 | bottom: 0, 94 | x: 0, 95 | y: 0, 96 | width: 0, 97 | height: 0, 98 | }; 99 | }; 100 | 101 | let onEnterFired = false; 102 | const onEnter = () => { 103 | onEnterFired = true; 104 | }; 105 | 106 | component = mount(, { 107 | attachTo: document.getElementById('root'), 108 | }); 109 | 110 | expect(onEnterFired).toBe(false); 111 | component.unmount(); 112 | }); 113 | 114 | test('ScrollTrigger fires onEnter callback when entering viewport', () => { 115 | window.resizeTo(1024, 768); 116 | window.Element.prototype.getBoundingClientRect = () => { 117 | return { 118 | left: 0, 119 | top: 1572, 120 | right: 0, 121 | bottom: 0, 122 | x: 0, 123 | y: 0, 124 | width: 0, 125 | height: 0, 126 | }; 127 | }; 128 | 129 | let onEnterFired = false; 130 | const onEnter = () => { 131 | onEnterFired = true; 132 | }; 133 | 134 | component = mount(, { 135 | attachTo: document.getElementById('root'), 136 | }); 137 | 138 | window.Element.prototype.getBoundingClientRect = () => { 139 | return { 140 | left: 0, 141 | top: 767, 142 | right: 0, 143 | bottom: 0, 144 | x: 0, 145 | y: 0, 146 | width: 0, 147 | height: 0, 148 | }; 149 | }; 150 | window.scrollTo(0, 768); 151 | 152 | expect(onEnterFired).toBe(true); 153 | component.unmount(); 154 | }); 155 | 156 | test('ScrollTrigger fires onExit callback when exiting viewport', () => { 157 | window.resizeTo(1024, 768); 158 | 159 | let onExitFired = false; 160 | const onExit = () => { 161 | onExitFired = true; 162 | }; 163 | 164 | component = mount(, { 165 | attachTo: document.getElementById('root'), 166 | }); 167 | 168 | window.Element.prototype.getBoundingClientRect = () => { 169 | return { 170 | left: 0, 171 | top: -2, 172 | right: 0, 173 | bottom: -1, 174 | x: 0, 175 | y: 0, 176 | width: 0, 177 | height: 1, 178 | }; 179 | }; 180 | 181 | window.scrollTo(0, 769); 182 | 183 | expect(onExitFired).toBe(true); 184 | component.unmount(); 185 | }); 186 | 187 | test('ScrollTrigger fires onProgress callback while scrolling through viewport', () => { 188 | window.resizeTo(1024, 768); 189 | 190 | let onProgressInitial = true; 191 | let onProgressCount = 0; 192 | const onProgress = () => { 193 | if (!onProgressInitial) { 194 | onProgressCount++; 195 | } 196 | 197 | onProgressInitial = false; 198 | }; 199 | 200 | component = mount(, { 201 | attachTo: document.getElementById('root'), 202 | }); 203 | 204 | window.scrollTo(1, 0); 205 | window.scrollTo(2, 0); 206 | 207 | expect(onProgressCount).toBe(2); 208 | component.unmount(); 209 | }); 210 | 211 | test('ScrollTrigger only fires onProgress once due to throttleScroll value', () => { 212 | window.resizeTo(1024, 768); 213 | 214 | let onProgressInitial = true; 215 | let onProgressCount = 0; 216 | const onProgress = () => { 217 | if (!onProgressInitial) { 218 | onProgressCount++; 219 | } 220 | 221 | onProgressInitial = false; 222 | }; 223 | 224 | component = mount(, { 225 | attachTo: document.getElementById('root'), 226 | }); 227 | 228 | window.scrollTo(1, 0); 229 | window.scrollTo(2, 0); 230 | 231 | expect(onProgressCount).toBe(1); 232 | component.unmount(); 233 | }); 234 | 235 | test('ScrollTrigger fires onProgress callback when browser is resized', () => { 236 | window.resizeTo(1024, 768); 237 | 238 | let onProgressFired = false; 239 | const onProgress = () => { 240 | onProgressFired = true; 241 | }; 242 | 243 | component = mount(, { 244 | attachTo: document.getElementById('root'), 245 | }); 246 | 247 | window.resizeTo(320, 568); 248 | 249 | expect(onProgressFired).toBe(true); 250 | component.unmount(); 251 | }); 252 | 253 | test('ScrollTrigger properly accepts throttleScroll prop change and updates accordingly', () => { 254 | component = mount(, { 255 | attachTo: document.getElementById('root'), 256 | }); 257 | 258 | component.setProps({ 259 | throttleScroll: 0, 260 | }); 261 | 262 | expect(component.prop('throttleScroll')).toEqual(0); 263 | component.unmount(); 264 | }); 265 | 266 | test('ScrollTrigger properly accepts throttleResize prop change and updates accordingly', () => { 267 | component = mount(, { 268 | attachTo: document.getElementById('root'), 269 | }); 270 | 271 | component.setProps({ 272 | throttleResize: 0, 273 | }); 274 | 275 | expect(component.prop('throttleResize')).toEqual(0); 276 | component.unmount(); 277 | }); 278 | }); 279 | -------------------------------------------------------------------------------- /tools/TestComponent.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import cleanProps from 'clean-react-props'; 3 | 4 | class TestComponent extends Component { 5 | render() { 6 | const { 7 | children, 8 | } = this.props; 9 | 10 | return ( 11 |
{children}
12 | ); 13 | } 14 | }; 15 | 16 | export default TestComponent; 17 | -------------------------------------------------------------------------------- /tools/babel-preset.js: -------------------------------------------------------------------------------- 1 | const BABEL_ENV = process.env.BABEL_ENV; 2 | const building = BABEL_ENV != undefined && BABEL_ENV !== 'cjs'; 3 | 4 | const plugins = [ 5 | '@babel/plugin-proposal-object-rest-spread', 6 | ]; 7 | 8 | if (process.env.NODE_ENV === 'production') { 9 | plugins.push( 10 | 'babel-plugin-dev-expression', 11 | 'babel-plugin-transform-react-remove-prop-types' 12 | ); 13 | } 14 | 15 | module.exports = () => { 16 | return { 17 | env: { 18 | test: { 19 | presets: [['@babel/preset-env'], '@babel/preset-react'], 20 | }, 21 | }, 22 | presets: [ 23 | ['@babel/preset-env', { 24 | 'loose': true, 25 | 'modules': building ? false : 'commonjs' 26 | }], 27 | '@babel/preset-react' 28 | ], 29 | plugins: plugins, 30 | }; 31 | }; 32 | -------------------------------------------------------------------------------- /tools/build.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const execSync = require('child_process').execSync; 3 | const prettyBytes = require('pretty-bytes'); 4 | const gzipSize = require('gzip-size'); 5 | 6 | const exec = (command, extraEnv) => { 7 | execSync(command, { 8 | stdio: 'inherit', 9 | env: Object.assign({}, process.env, extraEnv), 10 | }); 11 | }; 12 | 13 | console.log('Building CommonJS modules ...'); 14 | 15 | exec('babel src -d . --ignore __tests__,**/*.test.js', { 16 | BABEL_ENV: 'cjs', 17 | }); 18 | 19 | console.log('\nBuilding ES modules ...'); 20 | 21 | exec('babel src -d es --ignore __tests__,**/*.test.js', { 22 | BABEL_ENV: 'es', 23 | }); 24 | 25 | console.log('\nBuilding react-scroll-trigger.js ...'); 26 | 27 | exec('rollup -c -f umd -o umd/react-scroll-trigger.js', { 28 | BABEL_ENV: 'umd', 29 | NODE_ENV: 'development', 30 | }); 31 | 32 | console.log('\nBuilding react-scroll-trigger.min.js ...'); 33 | 34 | exec('rollup -c -f umd -o umd/react-scroll-trigger.min.js', { 35 | BABEL_ENV: 'umd', 36 | NODE_ENV: 'production', 37 | }); 38 | 39 | const size = gzipSize.sync( 40 | fs.readFileSync('umd/react-scroll-trigger.min.js') 41 | ); 42 | 43 | console.log('\ngzipped, the UMD build is %s', prettyBytes(size)); 44 | -------------------------------------------------------------------------------- /tools/jest-setup.js: -------------------------------------------------------------------------------- 1 | import jsdom from 'jsdom'; 2 | const { JSDOM } = jsdom; 3 | 4 | global.document = new JSDOM('
'); 5 | global.window = document.parentWindow; 6 | 7 | global.document.scrollingElement = { 8 | clientWidth: 1024, 9 | clientHeight: 768, 10 | scrollTop: 0, 11 | scrollLeft: 0, 12 | }; 13 | 14 | global.window.resizeTo = (width, height) => { 15 | global.window.innerWidth = width || global.window.innerWidth; 16 | global.window.innerHeight = height || global.window.innerHeight; 17 | global.document.scrollingElement = Object.assign({}, global.document.scrollingElement, { 18 | clientWidth: global.window.innerWidth, 19 | clientHeight: global.window.innerHeight, 20 | }); 21 | global.window.dispatchEvent(new Event('resize')); 22 | }; 23 | 24 | global.window.scrollTo = (top, left) => { 25 | global.document.scrollingElement = Object.assign({}, global.document.scrollingElement, { 26 | scrollTop: top || global.document.scrollingElement.scrollTop, 27 | scrollLeft: left || global.document.scrollingElement.scrollLeft, 28 | }); 29 | global.window.dispatchEvent(new Event('scroll')); 30 | }; 31 | 32 | global.window.Element.prototype.getBoundingClientRect = () => { 33 | return { 34 | left: 0, 35 | top: 0, 36 | right: 0, 37 | bottom: 0, 38 | x: 0, 39 | y: 0, 40 | width: 0, 41 | height: 0, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | // TypeScript Version: 3.0 2 | 3 | import { Component, ReactNode } from 'react'; 4 | 5 | export interface ScrollTriggerEventArgs { 6 | progress: number; 7 | velocity: number; 8 | } 9 | 10 | export interface ScrollTriggerProps { 11 | component?: ReactNode; 12 | containerRef?: HTMLElement | string; 13 | throttleResize?: number; 14 | throttleScroll?: number; 15 | triggerOnLoad?: boolean; 16 | onEnter?: (args?: ScrollTriggerEventArgs) => void; 17 | onExit?: (args?: ScrollTriggerEventArgs) => void; 18 | onProgress?: (args?: ScrollTriggerEventArgs) => void; 19 | } 20 | 21 | declare class ScrollTrigger extends Component {} 22 | 23 | export default ScrollTrigger; 24 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "lib": ["es6"], 5 | "noImplicitAny": true, 6 | "noImplicitThis": true, 7 | "strictNullChecks": true, 8 | "strictFunctionTypes": true, 9 | "noEmit": true 10 | } 11 | } 12 | --------------------------------------------------------------------------------