├── .editorconfig ├── .github └── workflows │ ├── build-test.yml │ └── gh-pages.yml ├── .gitignore ├── .yarnrc.yml ├── README.md ├── biome.json ├── example ├── index.html ├── package.json ├── postcss.config.js ├── src │ ├── App.jsx │ ├── ComponentExample.jsx │ ├── HookExample.jsx │ ├── global.css │ └── main.jsx ├── tailwind.config.js └── vite.config.ts ├── gifs ├── example.gif └── example_inner.gif ├── package.json ├── src ├── .eslintrc ├── component │ └── index.tsx ├── hook │ ├── index.test.tsx │ └── index.tsx └── index.tsx ├── tsconfig.json ├── vite.config.ts └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.github/workflows/build-test.yml: -------------------------------------------------------------------------------- 1 | name: Build & Test 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4 10 | - run: corepack enable 11 | - uses: actions/setup-node@v4 12 | with: 13 | node-version: '20' 14 | cache: 'yarn' 15 | - run: yarn --immutable 16 | - run: yarn lint 17 | - run: yarn test 18 | - name: 'Report Coverage' 19 | if: always() 20 | uses: davelosert/vitest-coverage-report-action@v2 21 | -------------------------------------------------------------------------------- /.github/workflows/gh-pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy example app to Github Pages 2 | 3 | on: 4 | push: 5 | branches: ['main'] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | pages: write 11 | id-token: write 12 | 13 | concurrency: 14 | group: 'pages' 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | deploy: 19 | environment: 20 | name: github-pages 21 | url: ${{ steps.deployment.outputs.page_url }} 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v4 25 | - run: corepack enable 26 | - uses: actions/setup-node@v4 27 | with: 28 | node-version: 20 29 | cache: 'yarn' 30 | - name: Install dependencies 31 | run: yarn --immutable 32 | - name: Build lib 33 | run: yarn build 34 | - name: Build 35 | run: yarn build:example 36 | - name: Setup Pages 37 | uses: actions/configure-pages@v4 38 | - name: Upload artifact 39 | uses: actions/upload-pages-artifact@v3 40 | with: 41 | path: './example/dist' 42 | - name: Deploy to GitHub Pages 43 | id: deployment 44 | uses: actions/deploy-pages@v4 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | .eslintcache 12 | 13 | # misc 14 | .DS_Store 15 | .env 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .idea 26 | .vscode 27 | coverage/ 28 | 29 | # yarn 4 30 | .pnp.* 31 | .yarn/* 32 | !.yarn/patches 33 | !.yarn/plugins 34 | !.yarn/releases 35 | !.yarn/sdks 36 | !.yarn/versions -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-bottom-scroll-listener [![npm](https://img.shields.io/npm/dm/react-bottom-scroll-listener.svg)](https://www.npmjs.com/package/react-bottom-scroll-listener) [![NPM version](https://img.shields.io/npm/v/react-bottom-scroll-listener.svg?style=flat)](https://www.npmjs.com/package/react-bottom-scroll-listener) [![npm bundle size (minified)](https://img.shields.io/bundlephobia/minzip/react-bottom-scroll-listener.svg)](https://github.com/karl-run/react-bottom-scroll-listener) 2 | 3 | A simple **React hook** and **React component** that lets you listen for when you have scrolled to the bottom. 4 | 5 | ### Window 6 | 7 | ![Example](./docs/example.gif) 8 | 9 | ### Container 10 | 11 | ![Example](./docs/example_inner.gif) 12 | 13 | ## Installation 14 | 15 | npm: 16 | `npm install react-bottom-scroll-listener` 17 | 18 | yarn: 19 | `yarn add react-bottom-scroll-listener` 20 | 21 | ## Migrating to V5 22 | 23 | Version 5 is only a refactor for the hook to use an options parameter, instead 24 | of relying of function parameters which were starting to get out of hand. 25 | 26 | #### If you are using the component, there are no breaking changes 27 | 28 | If your hook looks like this: 29 | 30 | ```tsx 31 | useBottomScrollListener(callback, 0, 200, undefined, true); 32 | ``` 33 | 34 | You will have to change it to use the options parameter: 35 | 36 | ``` 37 | useBottomScrollListener(callback, { 38 | offset: 100, 39 | debounce: 0, 40 | triggerOnNoScroll: true 41 | }) 42 | ``` 43 | 44 | Remember that you can omit any values that are using the defaults! The defaults are ase following: 45 | 46 | ``` 47 | offset: 0, 48 | debounce: 200, 49 | debounceOptions: { leading: true }, 50 | triggerOnNoScroll: false, 51 | ``` 52 | 53 | So for the average use case, you are probably only setting one of these values, so your hook 54 | might look like this: 55 | 56 | ``` 57 | useBottomScrollListener(callback, { triggerOnNoScroll: true }) 58 | ``` 59 | 60 | You can refer to the Usage-section for more details. 61 | 62 | ## Usage 63 | 64 | ### Hook 65 | 66 | [Full example](/example/src/HookExample.js) 67 | 68 | #### On bottom of entire screen 69 | 70 | Use the hook in any functional component, the callback will be invoked 71 | when the user scrolls to the bottom of the document 72 | 73 | ```jsx 74 | import { useBottomScrollListener } from 'react-bottom-scroll-listener'; 75 | 76 | useBottomScrollListener(callback); 77 | ``` 78 | 79 | #### On bottom of specific container 80 | 81 | Use the hook in any functional component, use the ref given from the hook 82 | and pass it to the element you want to use as a scroll container 83 | 84 | The callback will be invoked when the user scrolls to the bottom of the container 85 | 86 | ```jsx 87 | import { useBottomScrollListener } from 'react-bottom-scroll-listener'; 88 | 89 | const scrollRef = useBottomScrollListener(callback); 90 | 91 |
Callback will be invoked when this container is scrolled to bottom.
; 92 | ``` 93 | 94 | **Parameters** 95 | 96 | ``` 97 | useBottomScrollListener( 98 | // Required callback that will be invoked when scrolled to bottom 99 | onBottom: () => void, 100 | // Options, entirely optional, you can provide one or several to overwrite the defaults 101 | options?: { 102 | // Offset from bottom of page in pixels. E.g. 300 will trigger onBottom 300px from the bottom of the page 103 | offset?: number 104 | // Optional debounce in milliseconds, defaults to 200ms 105 | debounce?: number 106 | // Overwrite the debounceOptions for lodash.debounce, default to { leading: true } 107 | debounceOptions?: DebounceOptions 108 | // If container is too short, enables a trigger of the callback if that happens, defaults to false 109 | triggerOnNoScroll?: boolean 110 | }, 111 | ); // returns React.MutableRefObject Optionally you can use this to pass to a element to use that as the scroll container 112 | ``` 113 | 114 | ### Component 115 | 116 | [Full example](/example/src/ComponentExample.js) 117 | 118 | #### On bottom of entire screen 119 | 120 | Simply have the BottomScrollListener anywhere in your application and pass it a function as `onBottom`-prop. 121 | 122 | ```jsx 123 | import BottomScrollListener from 'react-bottom-scroll-listener'; 124 | 125 | ; 126 | ``` 127 | 128 | #### On bottom of specific container 129 | 130 | Pass the BottomScrollListener a function inside the JSX_tag, receive the `scrollRef` in this function from the BottomScrollListener 131 | and pass it to the component you want to listen for a scroll event on. 132 | 133 | ```jsx 134 | import BottomScrollListener from 'react-bottom-scroll-listener'; 135 | 136 | 137 | {(scrollRef) =>
Callback will be invoked when this container is scrolled to bottom.
} 138 |
; 139 | ``` 140 | 141 | > Those are some weird children, what's going on? 142 | 143 | This pattern is called "function as a child". What this allows is that the BottomScrollListener can pass you a `React.RefObject`. This 144 | `React.RefObject` can then be passed to whatever component you want to be notified when you hit the bottom of. Without this it would be 145 | difficult to attach event listeners for scrolling to an arbitrary element. 146 | 147 | **Props** 148 | 149 | | Property | Type | Default | Description | 150 | | ----------------- | :----------------------: | :-------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 151 | | onBottom | Function | null | **(required):** callback invoked when bottom is reached | 152 | | debounce | number | 200 | milliseconds, how much debounce there should be on the callback | 153 | | offset | number | 0 | offset from bottom in pixels. E.g. 300 if it should invoke `onBottom` 300px before the bottom. | 154 | | debounceOptions | DebounceOptions | {leading: true} | see the lodash.debounce options: see https://lodash.com/docs/4.17.15#debounce | 155 | | triggerOnNoScroll | boolean | false | if container is too short, enables a trigger of the callback if that happens | 156 | | children | React.Node _or_ Function | null | Not required, but you can use this to wrap your components. Most useful when you have some conditional rendering. If this is a function, that function will receive a React.RefObject that _needs_ to be passed to a child element. This element will then be used as the scroll container. | 157 | 158 | # Migrating from 2.x.x to 3.x.x 159 | 160 | There are no breaking changes except that the required version of React is now 16.8.0. If you are on an 161 | older version of React you can either upgrade React, or stay on version 2.x.x. If you already 162 | are on a newer version of React you don't have to do anything, except maybe try out the new hook implementation. :) 163 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.3/schema.json", 3 | "vcs": { 4 | "enabled": false, 5 | "clientKind": "git", 6 | "useIgnoreFile": false 7 | }, 8 | "files": { 9 | "ignoreUnknown": false, 10 | "ignore": [] 11 | }, 12 | "formatter": { 13 | "enabled": false 14 | }, 15 | "organizeImports": { 16 | "enabled": true 17 | }, 18 | "linter": { 19 | "enabled": true, 20 | "rules": { 21 | "recommended": true 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | react-bottom-scroll-listener 10 | 11 | 12 | 13 | 16 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-bottom-scroll-listener-example", 3 | "homepage": "https://karl-run.github.io/react-bottom-scroll-listener", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "type": "module", 8 | "dependencies": { 9 | "prop-types": "^15.8.1", 10 | "react": "18.3.1", 11 | "react-bottom-scroll-listener": "link:..", 12 | "react-dom": "18.3.1", 13 | "react-toggle": "^4.1.3" 14 | }, 15 | "browserslist": [ 16 | ">0.2%", 17 | "not dead", 18 | "not ie <= 11", 19 | "not op_mini all" 20 | ], 21 | "devDependencies": { 22 | "@types/prop-types": "^15.7.14", 23 | "autoprefixer": "^10.4.21", 24 | "postcss": "^8.5.3", 25 | "tailwindcss": "^3.4.17", 26 | "vite": "^6.3.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /example/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /example/src/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import Toggle from 'react-toggle' 3 | 4 | import ComponentExample from './ComponentExample' 5 | import HookExample from './HookExample' 6 | 7 | const App = () => { 8 | const [hookExample, setHookExample] = useState(true) 9 | const [alertOnBottom, setAlertOnBottom] = useState(true) 10 | 11 | return ( 12 |
13 |
14 |

react-bottom-scroll-listener

15 |
16 | 25 | 34 |
35 |
36 | {hookExample ? : } 37 |
38 | ) 39 | } 40 | 41 | export default App 42 | -------------------------------------------------------------------------------- /example/src/ComponentExample.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | import 'react-toggle/style.css' 4 | 5 | import { BottomScrollListener } from 'react-bottom-scroll-listener' 6 | 7 | class ComponentExample extends Component { 8 | handleOnDocumentBottom = () => { 9 | console.log(`I am at bottom! ${Math.round(performance.now())}`) 10 | 11 | if (this.props.alertOnBottom) { 12 | alert('Bottom hit! Too slow? Reduce "debounce" value in props') 13 | } 14 | } 15 | 16 | handleContainerOnBottom = () => { 17 | console.log(`I am at bottom in optional container! ${Math.round(performance.now())}`) 18 | 19 | if (this.props.alertOnBottom) { 20 | alert('Bottom of this container hit! Too slow? Reduce "debounce" value in props') 21 | } 22 | } 23 | 24 | render() { 25 | return ( 26 |
27 | {/* If you want to listen for the bottom of a specific container you need to forward 28 | a scrollRef as a ref to your container */} 29 | 30 | {(scrollRef) => ( 31 |
35 |

Callback when this container hits bottom

36 |
Scroll down! ▼▼▼
37 |
A bit more... ▼▼
38 |
Almost there... ▼
39 |
You've reached the bottom!
40 |
41 | )} 42 |
43 | 44 |
45 |

Component example

46 |

Callback when document hits bottom

47 |
Scroll down! ▼▼▼
48 |
A bit more... ▼▼
49 |
Almost there... ▼
50 |
You've reached the bottom!
51 |
52 | 53 | {/* When you only want to listen to the bottom of "document", you can put it anywhere */} 54 | 55 |
56 | ) 57 | } 58 | } 59 | 60 | export default ComponentExample 61 | -------------------------------------------------------------------------------- /example/src/HookExample.jsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback } from 'react' 2 | 3 | import { useBottomScrollListener } from 'react-bottom-scroll-listener' 4 | 5 | const HookExample = ({ alertOnBottom }) => { 6 | const handleOnDocumentBottom = useCallback(() => { 7 | console.log(`I am at bottom! ${Math.round(performance.now())}`) 8 | 9 | if (alertOnBottom) { 10 | alert('Bottom hit!') 11 | } 12 | }, [alertOnBottom]) 13 | 14 | const handleContainerOnBottom = useCallback(() => { 15 | console.log(`I am at bottom in optional container! ${Math.round(performance.now())}`) 16 | 17 | if (alertOnBottom) { 18 | alert('Bottom of this container hit!') 19 | } 20 | }, [alertOnBottom]) 21 | 22 | /* This will trigger handleOnDocumentBottom when the body of the page hits the bottom */ 23 | useBottomScrollListener(handleOnDocumentBottom) 24 | 25 | /* This will trigger handleOnContainerBottom when the container that is passed the ref hits the bottom */ 26 | const containerRef = useBottomScrollListener(handleContainerOnBottom) 27 | 28 | return ( 29 |
30 |
34 |

Callback when this container hits bottom

35 |
Scroll down! ▼▼▼
36 |
A bit more... ▼▼
37 |
Almost there... ▼
38 |
You've reached the bottom!
39 |
40 |
41 |

Hook example

42 |

Callback when document hits bottom

43 |
Scroll down! ▼▼▼
44 |
A bit more... ▼▼
45 |
Almost there... ▼
46 |
You've reached the bottom!
47 |
48 |
49 | ) 50 | } 51 | 52 | export default HookExample 53 | -------------------------------------------------------------------------------- /example/src/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | @font-face { 7 | font-family: 'Roboto'; 8 | font-style: normal; 9 | font-weight: 400; 10 | font-display: swap; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /example/src/main.jsx: -------------------------------------------------------------------------------- 1 | import './global.css'; 2 | 3 | import React from 'react'; 4 | import { createRoot } from 'react-dom/client'; 5 | 6 | import App from './App'; 7 | 8 | const container = document.getElementById('root'); 9 | const root = createRoot(container); // createRoot(container!) if you use TypeScript 10 | root.render(); 11 | -------------------------------------------------------------------------------- /example/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | } 9 | -------------------------------------------------------------------------------- /example/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | import react from '@vitejs/plugin-react'; 3 | 4 | export default defineConfig({ 5 | plugins: [react()], 6 | build: { 7 | sourcemap: true, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /gifs/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karl-run/react-bottom-scroll-listener/646a37a88030f7d0764fd58e27f32336f035d1ff/gifs/example.gif -------------------------------------------------------------------------------- /gifs/example_inner.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/karl-run/react-bottom-scroll-listener/646a37a88030f7d0764fd58e27f32336f035d1ff/gifs/example_inner.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-bottom-scroll-listener", 3 | "type": "commonjs", 4 | "version": "5.2.0", 5 | "author": "Karl J. Overå", 6 | "license": "MIT", 7 | "description": "A simple React component that lets you listen for when you have scrolled to the bottom.", 8 | "repository": { 9 | "url": "git+https://github.com/karl-run/react-bottom-scroll-listener.git" 10 | }, 11 | "homepage": "https://github.com/karl-run/react-bottom-scroll-listener#readme", 12 | "bugs": { 13 | "url": "https://github.com/karl-run/react-bottom-scroll-listener/issues" 14 | }, 15 | "keywords": [ 16 | "react", 17 | "scrolling", 18 | "bottom", 19 | "listener", 20 | "callback" 21 | ], 22 | "files": [ 23 | "dist" 24 | ], 25 | "exports": { 26 | ".": { 27 | "types": "./dist/index.d.ts", 28 | "import": "./dist/react-bottom-scroll-listener.mjs", 29 | "require": "./dist/react-bottom-scroll-listener.js" 30 | } 31 | }, 32 | "main": "dist/react-bottom-scroll-listener.js", 33 | "module": "dist/react-bottom-scroll-listener.mjs", 34 | "types": "dist/index.d.ts", 35 | "source": "src/index.tsx", 36 | "packageManager": "yarn@4.9.1", 37 | "engines": { 38 | "node": ">=10" 39 | }, 40 | "workspaces": [ 41 | "example" 42 | ], 43 | "scripts": { 44 | "build": "vite build && tsc", 45 | "dev": "vite build --watch", 46 | "build:example": "cd example && vite build --base=/react-bottom-scroll-listener/", 47 | "dev:example": "cd example && vite", 48 | "lint": "biome check src", 49 | "test": "vitest --run --coverage", 50 | "prepublish": "run-s build" 51 | }, 52 | "dependencies": { 53 | "lodash.debounce": "^4.0.8" 54 | }, 55 | "peerDependencies": { 56 | "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 57 | "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 58 | }, 59 | "devDependencies": { 60 | "@biomejs/biome": "1.9.4", 61 | "@karl-run/prettier-config": "^1.0.1", 62 | "@testing-library/dom": "^10.4.0", 63 | "@testing-library/react": "^16.3.0", 64 | "@types/lodash.debounce": "^4.0.9", 65 | "@types/node": "^22.15.3", 66 | "@types/react": "^19.1.2", 67 | "@types/react-dom": "^19.1.2", 68 | "@vitejs/plugin-react": "^4.4.1", 69 | "@vitest/coverage-v8": "3.1.2", 70 | "jsdom": "^26.1.0", 71 | "npm-run-all": "^4.1.5", 72 | "prettier": "^3.5.3", 73 | "react": "^19.0.0", 74 | "react-dom": "^19.0.0", 75 | "typescript": "^5.8.3", 76 | "vite": "^6.3.3", 77 | "vitest": "^3.1.2" 78 | }, 79 | "prettier": "@karl-run/prettier-config" 80 | } 81 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/component/index.tsx: -------------------------------------------------------------------------------- 1 | import type { JSX, MutableRefObject } from 'react' 2 | import useBottomScrollListener, { type DebounceOptions } from '../hook' 3 | 4 | export interface BottomScrollListenerProps { 5 | /** 6 | * Required callback that will be invoked when scrolled to bottom 7 | */ 8 | onBottom: () => void 9 | 10 | /** 11 | * Offset from bottom of page in pixels. E.g. 300 will trigger onBottom 300px from the bottom of the page 12 | */ 13 | offset?: number 14 | 15 | /** 16 | * Optional debounce in milliseconds, defaults to 200ms 17 | */ 18 | debounce?: number 19 | 20 | /** 21 | * Options passed to lodash.debounce, see https://lodash.com/docs/4.17.15#debounce 22 | */ 23 | debounceOptions?: DebounceOptions 24 | 25 | /** 26 | * Triggers the onBottom callback when the page has no scrollbar, defaults to false 27 | */ 28 | triggerOnNoScroll?: boolean 29 | 30 | /** 31 | * Optional children to be rendered. 32 | * 33 | * If children passed is a function, that function will be passed a React.RefObject 34 | * that ref shall be passed to a child tag that will be used for the scrolling container. 35 | * */ 36 | children?: JSX.Element | ((ref: ((instance: T | null) => void) | MutableRefObject | null) => JSX.Element) 37 | } 38 | 39 | /** 40 | * A simple React component that lets you listen for when you have scrolled to the bottom. 41 | * 42 | * @param {BottomScrollListenerProps} props 43 | */ 44 | const BottomScrollListener = ({ 45 | children, 46 | onBottom, 47 | offset, 48 | debounce, 49 | debounceOptions, 50 | triggerOnNoScroll, 51 | }: BottomScrollListenerProps): JSX.Element | null => { 52 | const optionalScrollContainerRef = useBottomScrollListener(onBottom, { 53 | offset, 54 | debounce, 55 | debounceOptions, 56 | triggerOnNoScroll, 57 | }) 58 | 59 | if (!children) return null 60 | if (typeof children === 'function') return children(optionalScrollContainerRef) 61 | return children 62 | } 63 | 64 | export default BottomScrollListener 65 | -------------------------------------------------------------------------------- /src/hook/index.test.tsx: -------------------------------------------------------------------------------- 1 | import { afterEach, describe, expect, it, vi } from 'vitest' 2 | 3 | import { cleanup, fireEvent, render, renderHook, screen } from '@testing-library/react' 4 | import type React from 'react' 5 | 6 | import useBottomScrollListener from './' 7 | 8 | /* Mock out scrollHeight so we can change it before dispatching scroll event */ 9 | Object.defineProperty(HTMLElement.prototype, 'scrollHeight', { 10 | configurable: true, 11 | get: function () { 12 | return this._scrollHeight || 0 13 | }, 14 | set(val) { 15 | this._scrollHeight = val 16 | }, 17 | }) 18 | 19 | /* Mock out clientHeight so we can change it before dispatching scroll event in custom containers */ 20 | Object.defineProperty(HTMLElement.prototype, 'clientHeight', { 21 | configurable: true, 22 | get: function () { 23 | return this._clientHeight || 0 24 | }, 25 | set(val) { 26 | this._clientHeight = val 27 | }, 28 | }) 29 | 30 | describe('useBottomScrollListener', () => { 31 | describe('given no ref it should use the document and', () => { 32 | it('shall not invoke onBottom when body has not hit bottom', async () => { 33 | const onBottom = vi.fn() 34 | 35 | renderHook(() => useBottomScrollListener(onBottom, { offset: 0, debounce: 0 })) 36 | 37 | // window size is 768. 38 | // 768 + 400 = 1168, should not scroll 39 | // @ts-expect-error scrollHeight is a mock property 40 | document.documentElement.scrollHeight = 1200 41 | document.documentElement.scrollTop = 400 42 | 43 | window.dispatchEvent(new Event('scroll')) 44 | 45 | expect(onBottom).not.toHaveBeenCalled() 46 | }) 47 | 48 | it('shall invoke onBottom when body is exactly at bottom', async () => { 49 | const onBottom = vi.fn() 50 | 51 | renderHook(() => useBottomScrollListener(onBottom, { offset: 0, debounce: 0 })) 52 | 53 | // window size is 768. 54 | // 768 + 432 = 1200, should scroll 55 | // @ts-expect-error scrollHeight is a mock property 56 | document.documentElement.scrollHeight = 1200 57 | document.documentElement.scrollTop = 432 58 | 59 | window.dispatchEvent(new Event('scroll')) 60 | 61 | expect(onBottom).toHaveBeenCalledTimes(1) 62 | }) 63 | 64 | it('shall invoke onBottom when there is no place to scroll with triggerOnNoScroll true', () => { 65 | const onBottom = vi.fn() 66 | renderHook(() => useBottomScrollListener(onBottom, { triggerOnNoScroll: true })) 67 | expect(onBottom).toHaveBeenCalledTimes(1) 68 | }) 69 | }) 70 | 71 | describe('given a ref it should use the given ref and', () => { 72 | const TestComponent = ({ onBottom }: { onBottom: () => void }) => { 73 | const ref = useBottomScrollListener(onBottom, { offset: 0, debounce: 0 }) 74 | 75 | return ( 76 |
77 |

I am test

78 |
79 | ) 80 | } 81 | 82 | afterEach(cleanup) 83 | 84 | it('shall not invoke onBottom when container has not hit bottom', () => { 85 | const onBottom = vi.fn() 86 | render() 87 | 88 | const container: { clientHeight: number; scrollHeight: number; scrollTop: number } = 89 | screen.getByTestId('container') 90 | 91 | // container size is 600. 92 | // 600 + 300 = 900, should not scroll 93 | container.clientHeight = 600 94 | container.scrollHeight = 1000 95 | container.scrollTop = 300 96 | 97 | fireEvent.scroll(container as Element, { target: { scrollY: 300 } }) 98 | 99 | expect(onBottom).not.toHaveBeenCalled() 100 | }) 101 | 102 | it('shall invoke onBottom when container is exactly at bottom', () => { 103 | const onBottom = vi.fn() 104 | render() 105 | 106 | const container: { clientHeight: number; scrollHeight: number; scrollTop: number } = 107 | screen.getByTestId('container') 108 | 109 | // container size is 600. 110 | // 600 + 400 = 1000, should scroll 111 | container.clientHeight = 600 112 | container.scrollHeight = 1000 113 | container.scrollTop = 400 114 | 115 | fireEvent.scroll(container as Element, { target: { scrollY: 400 } }) 116 | 117 | expect(onBottom).toHaveBeenCalledTimes(1) 118 | }) 119 | }) 120 | }) 121 | -------------------------------------------------------------------------------- /src/hook/index.tsx: -------------------------------------------------------------------------------- 1 | import lodashDebounce from 'lodash.debounce' 2 | import { type RefObject, useCallback, useEffect, useMemo, useRef } from 'react' 3 | 4 | export type DebounceOptions = Parameters[2] 5 | 6 | const createCallback = (debounce: number, handleOnScroll: () => void, options: DebounceOptions): (() => void) => { 7 | if (debounce) { 8 | return lodashDebounce(handleOnScroll, debounce, options) 9 | } 10 | 11 | return handleOnScroll 12 | } 13 | 14 | /** 15 | * @description 16 | * A react hook that invokes a callback when user scrolls to the bottom 17 | * 18 | * @param onBottom Required callback that will be invoked when scrolled to bottom 19 | * @param {Object} options - Optional parameters 20 | * @param {number} [options.offset=0] - Offset from bottom of page in pixels. E.g. 300 will trigger onBottom 300px from the bottom of the page 21 | * @param {number} [options.debounce=200] - Optional debounce in milliseconds, defaults to 200ms 22 | * @param {DebounceOptions} [options.debounceOptions={leading=true}] - Options passed to lodash.debounce, see https://lodash.com/docs/4.17.15#debounce 23 | * @param {boolean} [options.triggerOnNoScroll=false] - Triggers the onBottom callback when the page has no scrollbar 24 | * @returns {RefObject} ref - If passed to a element as a ref, e.g. a div it will register scrolling to the bottom of that div instead of document viewport 25 | */ 26 | function useBottomScrollListener( 27 | onBottom: () => void, 28 | options?: { 29 | offset?: number 30 | debounce?: number 31 | debounceOptions?: DebounceOptions 32 | triggerOnNoScroll?: boolean 33 | }, 34 | ): RefObject { 35 | const { offset, triggerOnNoScroll, debounce, debounceOptions } = useMemo( 36 | () => ({ 37 | offset: options?.offset ?? 0, 38 | debounce: options?.debounce ?? 200, 39 | debounceOptions: options?.debounceOptions ?? { leading: true }, 40 | triggerOnNoScroll: options?.triggerOnNoScroll ?? false, 41 | }), 42 | [options?.offset, options?.debounce, options?.debounceOptions, options?.triggerOnNoScroll], 43 | ) 44 | 45 | const debouncedOnBottom = useMemo( 46 | () => createCallback(debounce, onBottom, debounceOptions), 47 | [debounce, onBottom, debounceOptions], 48 | ) 49 | const containerRef = useRef(null) 50 | const handleOnScroll = useCallback(() => { 51 | if (containerRef.current != null) { 52 | const scrollNode: T = containerRef.current 53 | const scrollContainerBottomPosition = Math.round(scrollNode.scrollTop + scrollNode.clientHeight) 54 | const scrollPosition = Math.round(scrollNode.scrollHeight - offset) 55 | 56 | if (scrollPosition <= scrollContainerBottomPosition) { 57 | debouncedOnBottom() 58 | } 59 | } else { 60 | const scrollNode: Element = document.scrollingElement || document.documentElement 61 | const scrollContainerBottomPosition = Math.round(scrollNode.scrollTop + window.innerHeight) 62 | const scrollPosition = Math.round(scrollNode.scrollHeight - offset) 63 | 64 | if (scrollPosition <= scrollContainerBottomPosition) { 65 | debouncedOnBottom() 66 | } 67 | } 68 | // ref dependency needed for the tests, doesn't matter for normal execution 69 | }, [offset, debouncedOnBottom]) 70 | 71 | useEffect((): (() => void) => { 72 | const ref: T | null = containerRef.current 73 | if (ref != null) { 74 | ref.addEventListener('scroll', handleOnScroll) 75 | } else { 76 | window.addEventListener('scroll', handleOnScroll) 77 | } 78 | 79 | if (triggerOnNoScroll) { 80 | handleOnScroll() 81 | } 82 | 83 | return () => { 84 | if (ref != null) { 85 | ref.removeEventListener('scroll', handleOnScroll) 86 | } else { 87 | window.removeEventListener('scroll', handleOnScroll) 88 | } 89 | } 90 | }, [handleOnScroll, triggerOnNoScroll]) 91 | 92 | return containerRef 93 | } 94 | 95 | export default useBottomScrollListener 96 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as BottomScrollListener } from './component' 2 | export type { BottomScrollListenerProps } from './component' 3 | export { default as useBottomScrollListener } from './hook' 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "skipLibCheck": true, 5 | "target": "es2022", 6 | "allowJs": true, 7 | "jsx": "react-jsx", 8 | "resolveJsonModule": true, 9 | "moduleDetection": "force", 10 | "isolatedModules": true, 11 | "verbatimModuleSyntax": true, 12 | "strict": true, 13 | "noUncheckedIndexedAccess": true, 14 | "noImplicitOverride": true, 15 | "module": "preserve", 16 | "lib": ["es2022", "dom", "dom.iterable"], 17 | "outDir": "dist", 18 | "emitDeclarationOnly": true, 19 | "declaration": true 20 | }, 21 | "include": ["src/**/*.ts", "src/**/*.tsx"], 22 | "exclude": ["node_modules"] 23 | } 24 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { resolve } from 'node:path' 2 | import { defineConfig } from 'vitest/config' 3 | import react from '@vitejs/plugin-react' 4 | 5 | export default defineConfig({ 6 | plugins: [react()], 7 | build: { 8 | lib: { 9 | entry: resolve(__dirname, 'src/index.tsx'), 10 | name: 'ReactBottomScrollListener', 11 | fileName: 'react-bottom-scroll-listener', 12 | formats: ['cjs', 'es'], 13 | }, 14 | rollupOptions: { 15 | external: ['react', 'lodash.debounce'], 16 | output: { 17 | globals: { 18 | react: 'React', 19 | 'lodash.debounce': 'LodashDebounce', 20 | }, 21 | }, 22 | }, 23 | }, 24 | test: { 25 | environment: 'jsdom', 26 | coverage: { 27 | reporter: ['text', 'json-summary', 'json'], 28 | exclude: ['example'], 29 | include: ['src'], 30 | }, 31 | }, 32 | }) 33 | --------------------------------------------------------------------------------