├── .npmrc ├── storybook ├── styles.css ├── postcss.config.js ├── stories │ ├── Intro.mdx │ ├── Recipes.mdx │ ├── story-utils.ts │ ├── useOnInView.story.tsx │ ├── InView.story.tsx │ ├── useInView.story.tsx │ └── elements.tsx ├── tailwind.config.js ├── .storybook │ ├── preview.ts │ ├── manager.ts │ ├── preview-head.html │ └── main.ts ├── tsconfig.json ├── package.json └── readme.md ├── pnpm-workspace.yaml ├── .gitignore ├── SECURITY.md ├── .npmignore ├── tsup.config.ts ├── .github ├── workflows │ ├── pkg-pr.yml │ └── test.yml └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── tsconfig.json ├── vitest.config.ts ├── biome.json ├── LICENSE ├── src ├── __tests__ │ ├── setup.test.ts │ ├── browser.test.tsx │ ├── observe.test.ts │ ├── InView.test.tsx │ ├── useInView.test.tsx │ └── useOnInView.test.tsx ├── index.tsx ├── useOnInView.tsx ├── useInView.tsx ├── observe.ts ├── InView.tsx └── test-utils.ts ├── .gitattributes ├── CONTRIBUTING.md ├── package.json ├── docs └── Recipes.md └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | ignore-workspace-root-check=true -------------------------------------------------------------------------------- /storybook/styles.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | -------------------------------------------------------------------------------- /storybook/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - storybook 3 | - . 4 | onlyBuiltDependencies: 5 | - '@biomejs/biome' 6 | - esbuild 7 | - msw 8 | - simple-git-hooks 9 | -------------------------------------------------------------------------------- /storybook/stories/Intro.mdx: -------------------------------------------------------------------------------- 1 | import { Markdown } from '@storybook/addon-docs/blocks'; 2 | import Readme from '../readme.md?raw'; 3 | 4 | {Readme} 5 | -------------------------------------------------------------------------------- /storybook/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import("@types/tailwindcss/tailwind-config").TailwindConfig } */ 2 | module.exports = { 3 | content: ["stories/**/*.tsx"], 4 | }; 5 | -------------------------------------------------------------------------------- /storybook/stories/Recipes.mdx: -------------------------------------------------------------------------------- 1 | import { Markdown } from '@storybook/addon-docs/blocks'; 2 | import Recipes from '../../docs/Recipes.md?raw'; 3 | 4 | {Recipes} 5 | -------------------------------------------------------------------------------- /storybook/.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import { themes } from "storybook/theming"; 2 | import "../styles.css"; 3 | 4 | export const parameters = { 5 | controls: { 6 | expanded: true, 7 | }, 8 | theme: { 9 | ...themes.dark, 10 | }, 11 | docs: { 12 | theme: themes.dark, 13 | }, 14 | }; 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .cache 3 | node_modules 4 | reports 5 | example 6 | lib/ 7 | dist/ 8 | test-utils/ 9 | coverage/ 10 | nuget 11 | npm-debug.log* 12 | package-lock.json 13 | .DS_store 14 | .eslintcache 15 | .idea 16 | .tern 17 | .tmp 18 | *.log 19 | storybook-static 20 | test-utils.js 21 | test-utils.d.ts 22 | __screenshots__ 23 | -------------------------------------------------------------------------------- /storybook/.storybook/manager.ts: -------------------------------------------------------------------------------- 1 | import { addons } from "storybook/manager-api"; 2 | import { themes } from "storybook/theming"; 3 | 4 | addons.setConfig({ 5 | theme: { 6 | ...themes.dark, 7 | brandTitle: "React IntersectionObserver", 8 | brandUrl: "https://github.com/thebuilder/react-intersection-observer", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you discover a security vulnerability within the `react-intersection-observer` package or have any security concerns, 6 | please [create a new issue](https://github.com/thebuilder/react-intersection-observer/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen) on the Github repository. 7 | 8 | We appreciate your responsible disclosure. 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Mac OSX Files 2 | .DS_Store 3 | .Trashes 4 | .LSOverride 5 | 6 | # Node Modules 7 | node_modules 8 | npm-debug.log 9 | 10 | # General Files 11 | .sass-cache 12 | .hg 13 | .idea 14 | .svn 15 | .cache 16 | .project 17 | .tmp 18 | .eslintignore 19 | biome.json 20 | .flowconfig 21 | .editorconfig 22 | storybook/.storybook 23 | pnpm-lock.yaml 24 | 25 | # Project files 26 | coverage 27 | storybook/stories 28 | tests 29 | example 30 | jest-setup.js 31 | __tests__ -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from "tsup"; 2 | 3 | const commons: Options = { 4 | minify: false, 5 | sourcemap: true, 6 | dts: true, 7 | clean: true, 8 | target: "es2018", 9 | external: ["react"], 10 | format: ["esm", "cjs"], 11 | }; 12 | 13 | export default defineConfig([ 14 | { 15 | ...commons, 16 | entry: ["src/index.tsx"], 17 | outDir: "dist", 18 | }, 19 | { 20 | ...commons, 21 | entry: { index: "src/test-utils.ts" }, 22 | outDir: "test-utils", 23 | sourcemap: false, 24 | }, 25 | ]); 26 | -------------------------------------------------------------------------------- /.github/workflows/pkg-pr.yml: -------------------------------------------------------------------------------- 1 | name: Publish Pull Requests 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | pr-package: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - run: npm install --global corepack@latest 9 | - run: corepack enable 10 | - uses: actions/checkout@v4 11 | - name: Setup Node.js 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20 15 | cache: "pnpm" 16 | - name: Install dependencies 17 | run: pnpm install 18 | - name: Build 19 | run: pnpm build 20 | - name: Publish preview package 21 | run: pnpx pkg-pr-new publish --no-template 22 | -------------------------------------------------------------------------------- /storybook/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "ESNext", 5 | "lib": ["es6", "es7", "esnext", "dom"], 6 | "jsx": "react-jsx", 7 | "moduleResolution": "node", 8 | "noImplicitReturns": true, 9 | "strict": true, 10 | "strictFunctionTypes": true, 11 | "suppressImplicitAnyIndexErrors": true, 12 | "noUnusedLocals": true, 13 | "pretty": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "downlevelIteration": true, 17 | "skipLibCheck": true, 18 | "listEmittedFiles": true, 19 | "noEmit": true 20 | }, 21 | "exclude": ["storybook-static"] 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "esnext", 4 | "target": "ESNext", 5 | "lib": ["es6", "es7", "esnext", "dom"], 6 | "types": ["vitest/globals", "vitest/browser"], 7 | "jsx": "react-jsx", 8 | "moduleResolution": "bundler", 9 | "noImplicitReturns": true, 10 | "strict": true, 11 | "strictFunctionTypes": true, 12 | "noUnusedLocals": true, 13 | "pretty": true, 14 | "allowSyntheticDefaultImports": true, 15 | "esModuleInterop": true, 16 | "downlevelIteration": true, 17 | "skipLibCheck": true, 18 | "listEmittedFiles": true, 19 | "noEmit": true 20 | }, 21 | "exclude": ["dist", "storybook"] 22 | } 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** A clear and 10 | concise description of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** A clear and concise description of what you 13 | want to happen. 14 | 15 | **Describe alternatives you've considered** A clear and concise description of 16 | any alternative solutions or features you've considered. 17 | 18 | **Additional context** Add any other context or screenshots about the feature 19 | request here. 20 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { playwright } from "@vitest/browser-playwright"; 2 | import { defineConfig } from "vitest/config"; 3 | 4 | export default defineConfig({ 5 | optimizeDeps: { 6 | include: ["@vitest/coverage-istanbul", "react", "react-dom/test-utils"], 7 | }, 8 | test: { 9 | environment: "node", 10 | globals: true, 11 | browser: { 12 | enabled: true, 13 | provider: playwright(), 14 | headless: true, 15 | instances: [{ browser: "chromium" }], 16 | }, 17 | coverage: { 18 | provider: "istanbul", 19 | include: ["src/**"], 20 | exclude: [ 21 | "**/__tests__", 22 | "**/*.test.{ts,tsx}", 23 | "**/*.{story,stories}.tsx", 24 | ], 25 | }, 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /storybook/.storybook/preview-head.html: -------------------------------------------------------------------------------- 1 | 5 | 28 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "/node_modules/@biomejs/biome/configuration_schema.json", 3 | "formatter": { 4 | "enabled": true, 5 | "indentStyle": "space" 6 | }, 7 | "css": { 8 | "parser": { 9 | "tailwindDirectives": true 10 | } 11 | }, 12 | "linter": { 13 | "enabled": true, 14 | "rules": { 15 | "recommended": true, 16 | "complexity": { 17 | "noForEach": "off" 18 | }, 19 | "correctness": { 20 | "noUnusedVariables": { 21 | "level": "warn", 22 | "options": { 23 | "ignoreRestSiblings": true 24 | } 25 | } 26 | }, 27 | "style": { 28 | "noUnusedTemplateLiteral": "off" 29 | }, 30 | "a11y": { 31 | "noSvgWithoutTitle": "off" 32 | } 33 | } 34 | }, 35 | "vcs": { 36 | "enabled": true, 37 | "clientKind": "git", 38 | "useIgnoreFile": true 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /storybook/.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/react-vite"; 2 | 3 | const config: StorybookConfig = { 4 | framework: "@storybook/react-vite", 5 | stories: [ 6 | "../stories/**/*.mdx", 7 | "../stories/**/*.@(story|stories).@(ts|tsx)", 8 | ], 9 | addons: ["@storybook/addon-docs", "@storybook/addon-vitest"], 10 | core: { 11 | builder: "@storybook/builder-vite", 12 | }, 13 | typescript: { 14 | reactDocgen: "react-docgen", // or false if you don't need docgen at all 15 | }, 16 | /** 17 | * In preparation for the vite build plugin, add the needed config here. 18 | * @param config {import('vite').UserConfig} 19 | */ 20 | async viteFinal(config) { 21 | if (config.optimizeDeps) { 22 | config.optimizeDeps.include = [ 23 | ...(config.optimizeDeps.include ?? []), 24 | "storybook/theming", 25 | ]; 26 | } 27 | return config; 28 | }, 29 | }; 30 | 31 | export default config; 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 React Intersection Observer authors 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 | -------------------------------------------------------------------------------- /storybook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "dev": "storybook dev -p 9000", 7 | "build": "storybook build", 8 | "lint": "biome lint ." 9 | }, 10 | "keywords": [], 11 | "author": "Daniel Schmidt", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/thebuilder/react-intersection-observer.git" 15 | }, 16 | "license": "MIT", 17 | "dependencies": { 18 | "@tailwindcss/postcss": "^4.1.16", 19 | "framer-motion": "^12.23.24", 20 | "react": "^19.2.0", 21 | "react-dom": "^19.2.0", 22 | "react-intersection-observer": "workspace:*", 23 | "tailwindcss": "^4.1.16" 24 | }, 25 | "devDependencies": { 26 | "@biomejs/biome": "^2.3.1", 27 | "@mdx-js/react": "^3.1.1", 28 | "@storybook/addon-docs": "9.1.15", 29 | "@storybook/addon-vitest": "9.1.15", 30 | "@storybook/builder-vite": "9.1.15", 31 | "@storybook/react": "9.1.15", 32 | "@storybook/react-vite": "9.1.15", 33 | "@types/react": "^19.2.2", 34 | "@types/react-dom": "^19.2.2", 35 | "@vitejs/plugin-react": "^5.1.0", 36 | "postcss": "^8.5.6", 37 | "storybook": "9.1.15", 38 | "typescript": "^5.9.3", 39 | "vite": "^7.1.12" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** Try and recreate the issue in a **Codesandbox**: 12 | 13 | - [Edit useInView](https://codesandbox.io/s/useinview-ud2vo?fontsize=14&hidenavigation=1&theme=dark) 14 | - [Edit InView render props](https://codesandbox.io/s/inview-render-props-hvhcb?fontsize=14&hidenavigation=1&theme=dark) 15 | - [Edit InView plain children](https://codesandbox.io/s/inview-plain-children-vv51y?fontsize=14&hidenavigation=1&theme=dark) 16 | 17 | **Expected behavior** A clear and concise description of what you expected to 18 | happen. 19 | 20 | **Screenshots** If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop (please complete the following information):** 23 | 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | 30 | - Device: [e.g. iPhone6] 31 | - OS: [e.g. iOS8.1] 32 | - Browser [e.g. stock browser, safari] 33 | - Version [e.g. 22] 34 | 35 | **Additional context** Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /src/__tests__/setup.test.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import { mockAllIsIntersecting, setupIntersectionMocking } from "../test-utils"; 3 | 4 | vi.hoisted(() => { 5 | // Clear the `vi` from global, so we can detect if this is a test env 6 | // @ts-expect-error 7 | window.vi = undefined; 8 | }); 9 | 10 | afterEach(() => { 11 | vi.resetAllMocks(); 12 | }); 13 | 14 | test("should warn if not running in test env", () => { 15 | vi.spyOn(console, "error").mockImplementation(() => {}); 16 | mockAllIsIntersecting(true); 17 | expect( 18 | console.error, 19 | ).toHaveBeenCalledWith(`React Intersection Observer was not configured to handle mocking. 20 | Outside Jest and Vitest, you might need to manually configure it by calling setupIntersectionMocking() and resetIntersectionMocking() in your test setup file. 21 | 22 | // test-setup.js 23 | import { resetIntersectionMocking, setupIntersectionMocking } from 'react-intersection-observer/test-utils'; 24 | 25 | beforeEach(() => { 26 | setupIntersectionMocking(vi.fn); 27 | }); 28 | 29 | afterEach(() => { 30 | resetIntersectionMocking(); 31 | });`); 32 | }); 33 | 34 | test('should not warn if running in test env with global "vi"', () => { 35 | vi.spyOn(console, "error").mockImplementation(() => {}); 36 | setupIntersectionMocking(vi.fn); 37 | mockAllIsIntersecting(true); 38 | expect(console.error).not.toHaveBeenCalled(); 39 | }); 40 | -------------------------------------------------------------------------------- /src/__tests__/browser.test.tsx: -------------------------------------------------------------------------------- 1 | import { cleanup, render, screen } from "@testing-library/react/pure"; 2 | import type { IntersectionOptions } from "../index"; 3 | import { useInView } from "../useInView"; 4 | 5 | afterEach(() => { 6 | cleanup(); 7 | }); 8 | 9 | const HookComponent = ({ options }: { options?: IntersectionOptions }) => { 10 | const [ref, inView] = useInView(options); 11 | 12 | return ( 13 |
19 | InView block 20 |
21 | ); 22 | }; 23 | 24 | test("should come into view on after rendering", async () => { 25 | render(); 26 | const wrapper = screen.getByTestId("wrapper"); 27 | await expect.element(wrapper).toHaveAttribute("data-inview", "true"); 28 | }); 29 | 30 | test("should come into view after scrolling", async () => { 31 | render( 32 | <> 33 |
34 | 35 |
36 | , 37 | ); 38 | const wrapper = screen.getByTestId("wrapper"); 39 | 40 | // Should not be inside the view 41 | expect(wrapper).toHaveAttribute("data-inview", "false"); 42 | 43 | // Scroll so the element comes into view 44 | window.scrollTo(0, window.innerHeight); 45 | // Should not be updated until intersection observer triggers 46 | expect(wrapper).toHaveAttribute("data-inview", "false"); 47 | 48 | await expect.element(wrapper).toHaveAttribute("data-inview", "true"); 49 | }); 50 | -------------------------------------------------------------------------------- /src/__tests__/observe.test.ts: -------------------------------------------------------------------------------- 1 | import { observe } from "../"; 2 | import { optionsToId } from "../observe"; 3 | import { intersectionMockInstance, mockIsIntersecting } from "../test-utils"; 4 | 5 | test("should be able to use observe", () => { 6 | const element = document.createElement("div"); 7 | const cb = vi.fn(); 8 | const unmount = observe(element, cb, { threshold: 0.1 }); 9 | 10 | mockIsIntersecting(element, true); 11 | expect(cb).toHaveBeenCalled(); 12 | 13 | // should be unmounted after unmount 14 | unmount(); 15 | expect(() => 16 | intersectionMockInstance(element), 17 | ).toThrowErrorMatchingInlineSnapshot( 18 | `[Error: Failed to find IntersectionObserver for element. Is it being observed?]`, 19 | ); 20 | }); 21 | 22 | test("should convert options to id", () => { 23 | expect( 24 | optionsToId({ 25 | root: document.createElement("div"), 26 | rootMargin: "10px 10px", 27 | threshold: [0, 1], 28 | }), 29 | ).toMatchInlineSnapshot(`"root_1,rootMargin_10px 10px,threshold_0,1"`); 30 | expect( 31 | optionsToId({ 32 | root: null, 33 | rootMargin: "10px 10px", 34 | threshold: 1, 35 | }), 36 | ).toMatchInlineSnapshot(`"root_0,rootMargin_10px 10px,threshold_1"`); 37 | expect( 38 | optionsToId({ 39 | threshold: 0, 40 | // @ts-expect-error 41 | trackVisibility: true, 42 | delay: 500, 43 | }), 44 | ).toMatchInlineSnapshot(`"delay_500,threshold_0,trackVisibility_true"`); 45 | expect( 46 | optionsToId({ 47 | threshold: 0, 48 | }), 49 | ).toMatchInlineSnapshot(`"threshold_0"`); 50 | expect( 51 | optionsToId({ 52 | threshold: [0, 0.5, 1], 53 | }), 54 | ).toMatchInlineSnapshot(`"threshold_0,0.5,1"`); 55 | }); 56 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - run: npm install --global corepack@latest 11 | - run: corepack enable 12 | - uses: actions/checkout@v4 13 | - name: Setup Node.js 14 | uses: actions/setup-node@v4 15 | with: 16 | node-version: 20 17 | cache: "pnpm" 18 | - name: Install dependencies 19 | run: pnpm install 20 | - name: Install playwright 21 | run: pnpm exec playwright install 22 | - name: Lint 23 | run: pnpm biome ci . 24 | - name: Test 25 | run: pnpm vitest --coverage 26 | env: 27 | CI: true 28 | - name: Build 29 | run: pnpm build 30 | 31 | test_matrix: 32 | runs-on: ubuntu-latest 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | react: 37 | - 17 38 | - 18 39 | - 19 40 | - latest 41 | steps: 42 | - run: npm install --global corepack@latest 43 | - run: corepack enable 44 | - uses: actions/checkout@v4 45 | - name: Setup Node.js 46 | uses: actions/setup-node@v4 47 | with: 48 | node-version: 20 49 | cache: "pnpm" 50 | - name: Install dependencies 51 | run: pnpm install 52 | - name: Install legacy testing-library 53 | if: ${{ startsWith(matrix.react, '17') }} 54 | run: pnpm add -D @testing-library/react@12.1.4 55 | - name: Install React types 56 | run: pnpm add -D @types/react@${{ matrix.react }} @types/react-dom@${{ matrix.react }} 57 | - name: Install ${{ matrix.react }} 58 | run: pnpm add -D react@${{ matrix.react }} react-dom@${{ matrix.react }} 59 | - name: Validate types 60 | run: pnpm tsc 61 | - name: Run test 62 | run: | 63 | pnpm exec playwright install 64 | pnpm test 65 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # From https://github.com/Danimoth/gitattributes/blob/master/Web.gitattributes 2 | 3 | # Handle line endings automatically for files detected as text 4 | # and leave all files detected as binary untouched. 5 | * text=auto 6 | 7 | # 8 | # The above will handle all files NOT found below 9 | # 10 | 11 | # 12 | ## These files are text and should be normalized (Convert crlf => lf) 13 | # 14 | 15 | # source code 16 | *.php text 17 | *.css text 18 | *.sass text 19 | *.scss text 20 | *.less text 21 | *.styl text 22 | *.js text eol=lf 23 | *.coffee text 24 | *.json text 25 | *.htm text 26 | *.html text 27 | *.xml text 28 | *.svg text 29 | *.txt text 30 | *.ini text 31 | *.inc text 32 | *.pl text 33 | *.rb text 34 | *.py text 35 | *.scm text 36 | *.sql text 37 | *.sh text 38 | *.bat text 39 | 40 | # templates 41 | *.ejs text 42 | *.hbt text 43 | *.jade text 44 | *.haml text 45 | *.hbs text 46 | *.dot text 47 | *.tmpl text 48 | *.phtml text 49 | 50 | # server config 51 | .htaccess text 52 | 53 | # git config 54 | .gitattributes text 55 | .gitignore text 56 | .gitconfig text 57 | 58 | # code analysis config 59 | .eslintignore text 60 | 61 | # misc config 62 | *.yaml text 63 | *.yml text 64 | .editorconfig text 65 | 66 | # build config 67 | *.npmignore text 68 | *.bowerrc text 69 | 70 | # Heroku 71 | Procfile text 72 | .slugignore text 73 | 74 | # Documentation 75 | *.md text 76 | LICENSE text 77 | AUTHORS text 78 | 79 | # Yarn 80 | yarn.lock text 81 | 82 | # 83 | ## These files are binary and should be left untouched 84 | # 85 | 86 | # (binary is a macro for -text -diff) 87 | *.png binary 88 | *.jpg binary 89 | *.jpeg binary 90 | *.gif binary 91 | *.ico binary 92 | *.mov binary 93 | *.mp4 binary 94 | *.mp3 binary 95 | *.flv binary 96 | *.fla binary 97 | *.swf binary 98 | *.gz binary 99 | *.zip binary 100 | *.7z binary 101 | *.ttf binary 102 | *.eot binary 103 | *.woff binary 104 | *.pyc binary 105 | *.pdf binary 106 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution Guidelines 2 | 3 | Welcome to `react-intersection-observer`! I'm thrilled that you're interested in 4 | contributing. Here are some guidelines to help you get started. 5 | 6 | The codebase is written in TypeScript, and split into two packages using PNPM 7 | workspaces: 8 | 9 | - `react-intersection-observer` - The main package, which contains the 10 | `useInView` hook and the `InView` component. 11 | - `storybook` - A Storybook project that is used to develop and test the 12 | `react-intersection-observer` package. 13 | 14 | ## Development 15 | 16 | Start by forking the repository, and after cloning it locally you can install 17 | the dependencies using [PNPM](https://pnpm.io/): 18 | 19 | ```shell 20 | pnpm install 21 | ``` 22 | 23 | Then you can start the Storybook development server with the `dev` task: 24 | 25 | ```shell 26 | pnpm dev 27 | ``` 28 | 29 | ## Semantic Versioning 30 | 31 | `react-intersection-observer` follows Semantic Versioning 2.0 as defined at 32 | http://semver.org. This means that releases will be numbered with the following 33 | format: 34 | 35 | `..` 36 | 37 | - Breaking changes and new features will increment the major version. 38 | - Backwards-compatible enhancements will increment the minor version. 39 | - Bug fixes and documentation changes will increment the patch version. 40 | 41 | ## Pull Request Process 42 | 43 | Fork the repository and create a branch for your feature/bug fix. 44 | 45 | - Add tests for your feature/bug fix. 46 | - Ensure that all tests pass before submitting your pull request. 47 | - Update the README.md file if necessary. 48 | - Ensure that your commits follow the conventions outlined in the next section. 49 | 50 | ### Commit Message Conventions 51 | 52 | - We use 53 | [semantic-release](https://github.com/semantic-release/semantic-release) to 54 | manage releases automatically. To ensure that releases are automatically 55 | versioned correctly, we follow the 56 | [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) 57 | Conventions. This means that your commit messages should have the following 58 | format: 59 | 60 | `: ` 61 | 62 | Here's what each part of the commit message means: 63 | 64 | - ``: The type of change that you're committing. Valid types include 65 | `feat` for new features, `fix` for bug fixes, `docs` for documentation 66 | changes, and `chore` for changes that don't affect the code itself (e.g. 67 | updating dependencies). 68 | - ``: A short description of the change. 69 | 70 | ### Code Style and Linting 71 | 72 | `react-intersection-observer` uses [Biome](https://biomejs.dev/) for code 73 | formatting and linting. Please ensure that your changes are formatted with Biome before 74 | submitting your pull request. 75 | 76 | ### Testing 77 | 78 | `react-intersection-observer` uses [Vitest](https://vitest.dev/) for testing. 79 | Please ensure that your changes are covered by tests, and that all tests pass 80 | before submitting your pull request. 81 | 82 | You can run the tests with the `test` task: 83 | 84 | ```shell 85 | pnpm test 86 | ``` 87 | -------------------------------------------------------------------------------- /storybook/stories/story-utils.ts: -------------------------------------------------------------------------------- 1 | import type { ArgTypes } from "@storybook/react"; 2 | import type { IntersectionOptions } from "react-intersection-observer"; 3 | 4 | export const argTypes: ArgTypes = { 5 | root: { 6 | table: { disable: true }, 7 | description: 8 | "The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.", 9 | }, 10 | rootMargin: { 11 | control: { type: "text" }, 12 | description: 13 | "Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left).", 14 | defaultValue: "0px", 15 | }, 16 | threshold: { 17 | control: { 18 | type: "range", 19 | min: 0, 20 | max: 1, 21 | step: 0.05, 22 | }, 23 | description: 24 | "Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an `array` of numbers, to create multiple trigger points.", 25 | }, 26 | triggerOnce: { 27 | control: { type: "boolean" }, 28 | description: "Only trigger the inView callback once", 29 | }, 30 | skip: { 31 | control: { type: "boolean" }, 32 | description: "Skip assigning the observer to the `ref`", 33 | }, 34 | initialInView: { 35 | control: { type: "boolean" }, 36 | description: 37 | "Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves.", 38 | }, 39 | fallbackInView: { 40 | control: { type: "boolean" }, 41 | description: 42 | "Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded", 43 | }, 44 | trackVisibility: { 45 | control: { type: "boolean" }, 46 | description: 47 | "IntersectionObserver v2 - Track the actual visibility of the element", 48 | }, 49 | delay: { 50 | control: { type: "number" }, 51 | description: 52 | "IntersectionObserver v2 - Set a minimum delay between notifications", 53 | }, 54 | onChange: { 55 | table: { disable: true }, 56 | action: "InView", 57 | }, 58 | }; 59 | 60 | export function getRoot(options: IntersectionOptions) { 61 | if (options.rootMargin && !options.root && window.self !== window.top) { 62 | return document as unknown as Element; 63 | } 64 | return options.root; 65 | } 66 | 67 | export function useValidateOptions(options: IntersectionOptions) { 68 | const finalOptions = { root: getRoot(options), ...options }; 69 | // @ts-expect-error 70 | finalOptions.as = undefined; 71 | if (!finalOptions.root) finalOptions.root = undefined; 72 | 73 | let error: string | undefined; 74 | try { 75 | new IntersectionObserver(() => {}, finalOptions); 76 | } catch (e) { 77 | if (e instanceof Error) { 78 | error = e.message.replace( 79 | "Failed to construct 'IntersectionObserver': ", 80 | "", 81 | ); 82 | } 83 | } 84 | 85 | return { options: finalOptions, error }; 86 | } 87 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import type * as React from "react"; 4 | 5 | export { InView } from "./InView"; 6 | export { defaultFallbackInView, observe } from "./observe"; 7 | export { useInView } from "./useInView"; 8 | export { useOnInView } from "./useOnInView"; 9 | 10 | type Omit = Pick>; 11 | 12 | export type ObserverInstanceCallback = ( 13 | inView: boolean, 14 | entry: IntersectionObserverEntry, 15 | ) => void; 16 | 17 | export type IntersectionChangeEffect = ( 18 | inView: boolean, 19 | entry: IntersectionObserverEntry & { target: TElement }, 20 | ) => void; 21 | 22 | interface RenderProps { 23 | inView: boolean; 24 | entry: IntersectionObserverEntry | undefined; 25 | // biome-ignore lint/suspicious/noExplicitAny: Ref could be anything 26 | ref: React.RefObject | ((node?: Element | null) => void); 27 | } 28 | 29 | export interface IntersectionOptions extends IntersectionObserverInit { 30 | /** The IntersectionObserver interface's read-only `root` property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the `root` is null, then the bounds of the actual document viewport are used.*/ 31 | root?: Element | Document | null; 32 | /** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */ 33 | rootMargin?: string; 34 | /** Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an `array` of numbers, to create multiple trigger points. */ 35 | threshold?: number | number[]; 36 | /** Only trigger the inView callback once */ 37 | triggerOnce?: boolean; 38 | /** Skip assigning the observer to the `ref` */ 39 | skip?: boolean; 40 | /** Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. */ 41 | initialInView?: boolean; 42 | /** Fallback to this inView state if the IntersectionObserver is unsupported, and a polyfill wasn't loaded */ 43 | fallbackInView?: boolean; 44 | /** IntersectionObserver v2 - Track the actual visibility of the element */ 45 | trackVisibility?: boolean; 46 | /** IntersectionObserver v2 - Set a minimum delay between notifications */ 47 | delay?: number; 48 | /** Call this function whenever the in view state changes */ 49 | onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; 50 | } 51 | 52 | export interface IntersectionObserverProps extends IntersectionOptions { 53 | /** 54 | * Children expects a function that receives an object 55 | * contain an `inView` boolean and `ref` that should be 56 | * assigned to the element root. 57 | */ 58 | children: (fields: RenderProps) => React.ReactNode; 59 | } 60 | 61 | /** 62 | * Types specific to the PlainChildren rendering of InView 63 | * */ 64 | export type PlainChildrenProps = IntersectionOptions & { 65 | children?: React.ReactNode; 66 | 67 | /** 68 | * Render the wrapping element as this element. 69 | * This needs to be an intrinsic element. 70 | * If you want to use a custom element, please use the useInView 71 | * hook to manage the ref explicitly. 72 | * @default `'div'` 73 | */ 74 | as?: React.ElementType; 75 | 76 | /** Call this function whenever the in view state changes */ 77 | onChange?: (inView: boolean, entry: IntersectionObserverEntry) => void; 78 | } & Omit, "onChange">; 79 | 80 | /** 81 | * The Hook response supports both array and object destructing 82 | */ 83 | export type InViewHookResponse = [ 84 | (node?: Element | null) => void, 85 | boolean, 86 | IntersectionObserverEntry | undefined, 87 | ] & { 88 | ref: (node?: Element | null) => void; 89 | inView: boolean; 90 | entry?: IntersectionObserverEntry; 91 | }; 92 | 93 | export type IntersectionEffectOptions = Omit< 94 | IntersectionOptions, 95 | "onChange" | "fallbackInView" | "initialInView" 96 | >; 97 | -------------------------------------------------------------------------------- /storybook/stories/useOnInView.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { useEffect, useMemo, useState } from "react"; 3 | import { 4 | type IntersectionEffectOptions, 5 | type IntersectionOptions, 6 | useOnInView, 7 | } from "react-intersection-observer"; 8 | import { 9 | EntryDetails, 10 | ErrorMessage, 11 | InViewBlock, 12 | InViewIcon, 13 | RootMargin, 14 | ScrollWrapper, 15 | Status, 16 | ThresholdMarker, 17 | } from "./elements"; 18 | import { argTypes, useValidateOptions } from "./story-utils"; 19 | 20 | type Props = IntersectionEffectOptions; 21 | 22 | type Story = StoryObj; 23 | 24 | const meta = { 25 | title: "useOnInView Hook", 26 | parameters: { 27 | controls: { 28 | expanded: true, 29 | }, 30 | }, 31 | argTypes: { 32 | ...argTypes, 33 | }, 34 | args: { 35 | threshold: 0, 36 | triggerOnce: false, 37 | skip: false, 38 | }, 39 | render: UseOnInViewRender, 40 | } satisfies Meta; 41 | 42 | export default meta; 43 | 44 | function UseOnInViewRender(rest: Props) { 45 | const { options, error } = useValidateOptions(rest as IntersectionOptions); 46 | 47 | const { onChange, initialInView, fallbackInView, ...observerOptions } = 48 | options; 49 | 50 | const effectOptions: IntersectionEffectOptions | undefined = error 51 | ? undefined 52 | : observerOptions; 53 | 54 | const [inView, setInView] = useState(false); 55 | const [events, setEvents] = useState([]); 56 | 57 | const optionsKey = useMemo( 58 | () => 59 | JSON.stringify({ 60 | threshold: effectOptions?.threshold, 61 | rootMargin: effectOptions?.rootMargin, 62 | trackVisibility: effectOptions?.trackVisibility, 63 | delay: effectOptions?.delay, 64 | triggerOnce: effectOptions?.triggerOnce, 65 | skip: effectOptions?.skip, 66 | }), 67 | [ 68 | effectOptions?.delay, 69 | effectOptions?.rootMargin, 70 | effectOptions?.skip, 71 | effectOptions?.threshold, 72 | effectOptions?.trackVisibility, 73 | effectOptions?.triggerOnce, 74 | ], 75 | ); 76 | 77 | // biome-ignore lint/correctness/useExhaustiveDependencies: reset when options change 78 | useEffect(() => { 79 | setEvents([]); 80 | setInView(false); 81 | }, [optionsKey]); 82 | 83 | const ref = useOnInView((isInView, entry) => { 84 | setInView(isInView); 85 | const seconds = 86 | Number.isFinite(entry.time) && entry.time >= 0 87 | ? (entry.time / 1000).toFixed(2) 88 | : undefined; 89 | const label = seconds 90 | ? `${isInView ? "Entered" : "Left"} viewport at ${seconds}s` 91 | : isInView 92 | ? "Entered viewport" 93 | : "Left viewport"; 94 | setEvents((prev) => [...prev, label]); 95 | }, effectOptions); 96 | 97 | if (error) { 98 | return {error}; 99 | } 100 | 101 | return ( 102 | 103 | 104 | 105 | 106 | 107 |
108 |

Event log

109 |
110 | {events.length === 0 ? ( 111 |

112 | Scroll this element in and out of view to trigger the callback. 113 |

114 | ) : ( 115 |
    116 | {events.map((event, index) => ( 117 |
  • {event}
  • 118 | ))} 119 |
120 | )} 121 |
122 |
123 | {effectOptions?.skip ? ( 124 |

125 | Observing is currently skipped. Toggle `skip` off to monitor the 126 | element. 127 |

128 | ) : null} 129 |
130 | 131 | 132 |
133 | ); 134 | } 135 | 136 | export const Basic: Story = { 137 | args: {}, 138 | }; 139 | 140 | export const TriggerOnce: Story = { 141 | args: { 142 | triggerOnce: true, 143 | }, 144 | }; 145 | 146 | export const SkipObserver: Story = { 147 | args: { 148 | skip: true, 149 | }, 150 | }; 151 | -------------------------------------------------------------------------------- /storybook/stories/InView.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import type { CSSProperties } from "react"; 3 | import { type IntersectionOptions, InView } from "react-intersection-observer"; 4 | import { action } from "storybook/actions"; 5 | import { 6 | EntryDetails, 7 | ErrorMessage, 8 | InViewBlock, 9 | InViewIcon, 10 | RootComponent, 11 | RootMargin, 12 | ScrollWrapper, 13 | Status, 14 | ThresholdMarker, 15 | } from "./elements"; 16 | import { argTypes, useValidateOptions } from "./story-utils"; 17 | 18 | type Props = IntersectionOptions & { 19 | style?: CSSProperties; 20 | className?: string; 21 | }; 22 | 23 | type Story = StoryObj; 24 | 25 | export default { 26 | title: "InView Component", 27 | component: InView, 28 | argTypes: { 29 | ...argTypes, 30 | style: { table: { disable: true } }, 31 | className: { table: { disable: true } }, 32 | }, 33 | render: InViewRender, 34 | } satisfies Meta; 35 | 36 | function InViewRender({ style, className, ...rest }: Props) { 37 | const { options, error } = useValidateOptions(rest); 38 | if (error) return {error}; 39 | 40 | return ( 41 | 42 | 43 | {({ ref, inView }) => ( 44 | <> 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | )} 54 | 55 | 56 | ); 57 | } 58 | 59 | export const Basic: Story = { 60 | args: { 61 | threshold: 0, 62 | }, 63 | }; 64 | 65 | export const WithRootMarginexport: Story = { 66 | args: { 67 | rootMargin: "25px 0px", 68 | threshold: 0, 69 | }, 70 | }; 71 | 72 | export const StartInView: Story = { 73 | args: { 74 | threshold: 0, 75 | initialInView: true, 76 | }, 77 | }; 78 | 79 | export const TallerThanViewport: Story = { 80 | args: { 81 | threshold: 0, 82 | style: { minHeight: "150vh" }, 83 | }, 84 | }; 85 | 86 | export const WithThreshold100percentage: Story = { 87 | args: { 88 | threshold: 1, 89 | }, 90 | }; 91 | 92 | export const WithThreshold50percentage: Story = { 93 | args: { 94 | threshold: 0.5, 95 | }, 96 | }; 97 | 98 | export const TallerThanViewportWithThreshold100percentage: Story = { 99 | args: { 100 | threshold: 1, 101 | style: { minHeight: "150vh" }, 102 | }, 103 | }; 104 | 105 | export const MultipleThresholds: Story = { 106 | args: { 107 | threshold: [0, 0.25, 0.5, 0.75, 1], 108 | }, 109 | argTypes: { 110 | threshold: { 111 | options: [0, 0.25, 0.5, 0.75, 1], 112 | control: { type: "multi-select" }, 113 | }, 114 | }, 115 | }; 116 | 117 | export const TriggerOnce: Story = { 118 | args: { 119 | threshold: 0, 120 | triggerOnce: true, 121 | }, 122 | }; 123 | 124 | export const Skip: Story = { 125 | args: { 126 | threshold: 1, 127 | skip: true, 128 | }, 129 | }; 130 | 131 | export const WithRoot: Story = { 132 | args: { 133 | threshold: 0, 134 | }, 135 | render: (props) => ( 136 | 137 | {(node) => } 138 | 139 | ), 140 | }; 141 | export const WithRootAndRootMargin: Story = { 142 | args: { 143 | rootMargin: "25px 0px", 144 | threshold: 0, 145 | }, 146 | render: (props) => ( 147 | 148 | {(node) => } 149 | 150 | ), 151 | }; 152 | 153 | export const multipleObservers = () => ( 154 | 155 | 156 | {({ ref, inView }) => ( 157 | <> 158 | 159 | 160 | 161 | 162 | )} 163 | 164 | 165 | {({ ref, inView }) => ( 166 | <> 167 | 168 | 169 | 170 | 171 | )} 172 | 173 | 174 | {({ ref, inView }) => ( 175 | <> 176 | 177 | 178 | 179 | 180 | )} 181 | 182 | 183 | ); 184 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-intersection-observer", 3 | "version": "10.0.0", 4 | "description": "Monitor if a component is inside the viewport, using IntersectionObserver API", 5 | "type": "commonjs", 6 | "source": "src/index.tsx", 7 | "main": "dist/index.js", 8 | "module": "dist/esm/index.js", 9 | "types": "dist/index.d.ts", 10 | "exports": { 11 | "./test-utils": { 12 | "import": { 13 | "types": "./test-utils/index.d.mts", 14 | "default": "./test-utils/index.mjs" 15 | }, 16 | "require": { 17 | "types": "./test-utils/index.d.ts", 18 | "default": "./test-utils/index.js" 19 | } 20 | }, 21 | ".": { 22 | "import": { 23 | "types": "./dist/index.d.mts", 24 | "default": "./dist/index.mjs" 25 | }, 26 | "require": { 27 | "types": "./dist/index.d.ts", 28 | "default": "./dist/index.js" 29 | } 30 | } 31 | }, 32 | "files": [ 33 | "dist", 34 | "test-utils" 35 | ], 36 | "author": "Daniel Schmidt", 37 | "license": "MIT", 38 | "sideEffects": false, 39 | "repository": { 40 | "type": "git", 41 | "url": "git+https://github.com/thebuilder/react-intersection-observer.git" 42 | }, 43 | "packageManager": "pnpm@10.5.2+sha512.da9dc28cd3ff40d0592188235ab25d3202add8a207afbedc682220e4a0029ffbff4562102b9e6e46b4e3f9e8bd53e6d05de48544b0c57d4b0179e22c76d1199b", 44 | "scripts": { 45 | "prebuild": "rm -rf dist lib", 46 | "build": "tsup && mkdir dist/esm && cp dist/index.mjs dist/esm/index.js", 47 | "postbuild": "attw --pack && publint && size-limit", 48 | "dev": "run-p dev:*", 49 | "dev:package": "tsup src/index.tsx --watch", 50 | "dev:storybook": "pnpm --filter storybook dev", 51 | "release": "bumpp && npm publish", 52 | "lint": "biome check .", 53 | "version": "pnpm build", 54 | "storybook:build": "pnpm build && pnpm --filter storybook build", 55 | "test": "vitest" 56 | }, 57 | "keywords": [ 58 | "react", 59 | "component", 60 | "hooks", 61 | "viewport", 62 | "intersection", 63 | "observer", 64 | "lazy load", 65 | "inview", 66 | "useInView", 67 | "useIntersectionObserver" 68 | ], 69 | "release": { 70 | "branches": [ 71 | "main", 72 | { 73 | "name": "beta", 74 | "prerelease": true 75 | } 76 | ], 77 | "plugins": [ 78 | "@semantic-release/commit-analyzer", 79 | "@semantic-release/release-notes-generator", 80 | "@semantic-release/npm", 81 | "@semantic-release/github" 82 | ] 83 | }, 84 | "simple-git-hooks": { 85 | "pre-commit": "npx lint-staged" 86 | }, 87 | "lint-staged": { 88 | "*.{js,json,css,md,ts,tsx}": [ 89 | "biome check --fix --no-errors-on-unmatched --files-ignore-unknown=true" 90 | ] 91 | }, 92 | "size-limit": [ 93 | { 94 | "path": "dist/index.mjs", 95 | "name": "InView", 96 | "import": "{ InView }", 97 | "limit": "1.5 kB" 98 | }, 99 | { 100 | "path": "dist/index.mjs", 101 | "name": "useInView", 102 | "import": "{ useInView }", 103 | "limit": "1.3 kB" 104 | }, 105 | { 106 | "path": "dist/index.mjs", 107 | "name": "useOnInView", 108 | "import": "{ useOnInView }", 109 | "limit": "1.1 kB" 110 | }, 111 | { 112 | "path": "dist/index.mjs", 113 | "name": "observe", 114 | "import": "{ observe }", 115 | "limit": "0.9 kB" 116 | } 117 | ], 118 | "peerDependencies": { 119 | "react": "^17.0.0 || ^18.0.0 || ^19.0.0", 120 | "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" 121 | }, 122 | "devDependencies": { 123 | "@arethetypeswrong/cli": "^0.18.2", 124 | "@biomejs/biome": "^2.3.1", 125 | "@size-limit/preset-small-lib": "^11.2.0", 126 | "@testing-library/jest-dom": "^6.9.1", 127 | "@testing-library/react": "^16.3.0", 128 | "@types/node": "^24.9.1", 129 | "@types/react": "^19.2.2", 130 | "@types/react-dom": "^19.2.2", 131 | "@vitejs/plugin-react": "^5.1.0", 132 | "@vitest/browser-playwright": "^4.0.4", 133 | "@vitest/coverage-istanbul": "^4.0.4", 134 | "bumpp": "^10.3.1", 135 | "lint-staged": "^16.2.6", 136 | "microbundle": "^0.15.1", 137 | "npm-run-all": "^4.1.5", 138 | "playwright": "^1.56.1", 139 | "publint": "^0.3.15", 140 | "react": "^19.2.0", 141 | "react-dom": "^19.2.0", 142 | "simple-git-hooks": "^2.13.1", 143 | "size-limit": "^11.2.0", 144 | "tsup": "^8.5.0", 145 | "typescript": "^5.9.3", 146 | "vitest": "^4.0.4" 147 | }, 148 | "peerDependenciesMeta": { 149 | "react-dom": { 150 | "optional": true 151 | } 152 | }, 153 | "pnpm": { 154 | "allowedDeprecatedVersions": { 155 | "rollup-plugin-terser": "*", 156 | "sourcemap-codec": "*", 157 | "source-map-resolve": "*", 158 | "source-map-url": "*", 159 | "stable": "*", 160 | "urix": "*" 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /docs/Recipes.md: -------------------------------------------------------------------------------- 1 | # Recipes 2 | 3 | The `IntersectionObserver` itself is just a simple but powerful tool. Here's a 4 | few ideas for how you can use it. 5 | 6 | ## Lazy image load 7 | 8 | It's actually easy to create your own lazy image loader, and this allows you to 9 | build it according to your needs. 10 | 11 | **Couple of tips** 12 | 13 | - Don't set the `src` (or `srcset`) on the image until it's visible. Images will 14 | always load their `src`, even if you set `display: none;`. 15 | - Make sure to set the 16 | [root margin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/rootMargin) 17 | for top and bottom, so the Intersection Observer gets triggered before the 18 | image enters the viewport. This gives the image a chance to be loaded before 19 | the user even sees it. Try to start with something like `200px 0px`, but 20 | experiment with it until you find the right value. 21 | - Set `triggerOnce`, so you don't keep monitoring for changes. 22 | - You should always create a wrapping element, that sets the correct aspect 23 | ratio for the image. You can set the padding bottom to be 24 | `${height / width * 100}%` to maintain aspect ratio. 25 | - Either hide the `` with CSS, or skip rendering it until it's inside the 26 | viewport. 27 | 28 | > [!TIP] 29 | > All modern browsers support the native `loading` attribute on `` tags, so unless you need 30 | > fine-grained control, you can skip the `IntersectionObserver` and use `loading="lazy"` instead. 31 | > 32 | > https://web.dev/articles/browser-level-image-lazy-loading 33 | 34 | ```jsx 35 | import React from "react"; 36 | import { useInView } from "react-intersection-observer"; 37 | 38 | const LazyImage = ({ width, height, src, ...rest }) => { 39 | const { ref, inView } = useInView({ 40 | triggerOnce: true, 41 | rootMargin: "200px 0px", 42 | }); 43 | 44 | return ( 45 |
53 | {inView ? ( 54 | 61 | ) : null} 62 |
63 | ); 64 | }; 65 | 66 | export default LazyImage; 67 | ``` 68 | 69 | **See [Codesandbox](https://codesandbox.io/embed/lazy-image-load-mjsgc)** 70 | 71 | ## Trigger animations 72 | 73 | Triggering animations once they enter the viewport is also a perfect use case 74 | for an IntersectionObserver. 75 | 76 | - Set `triggerOnce`, to only trigger the animation the first time. 77 | - Set `threshold`, to control how much of the element should be visible before 78 | firing the event. 79 | - Instead of `threshold`, you can use `rootMargin` to have a fixed amount be 80 | visible before triggering. Use a negative margin value, like `-100px 0px`, to 81 | have it go inwards. You can also use a percentage value, instead of pixels. 82 | 83 | ```jsx 84 | import React from "react"; 85 | import { useInView } from "react-intersection-observer"; 86 | 87 | const LazyAnimation = () => { 88 | const { ref, inView } = useInView({ 89 | triggerOnce: true, 90 | rootMargin: "-100px 0px", 91 | }); 92 | 93 | return ( 94 |
98 | 👋 99 |
100 | ); 101 | }; 102 | 103 | export default LazyAnimation; 104 | ``` 105 | 106 | ## Track impressions 107 | 108 | You can use `IntersectionObserver` to track when a user views your element, and 109 | fire an event on your tracking service. Consider using the `useOnInView` to 110 | trigger changes via a callback. 111 | 112 | - Set `triggerOnce`, to only trigger an event the first time the element enters 113 | the viewport. 114 | - Set `threshold`, to control how much of the element should visible before 115 | firing the event. 116 | - Instead of `threshold`, you can use `rootMargin` to have a fixed amount be 117 | visible before triggering. Use a negative margin value, like `-100px 0px`, to 118 | have it go inwards. You can also use a percentage value, instead of pixels. 119 | 120 | ```jsx 121 | import * as React from "react"; 122 | import { useOnInView } from "react-intersection-observer"; 123 | 124 | const TrackImpression = () => { 125 | const ref = useOnInView((inView) => { 126 | if (inView) { 127 | // Fire a tracking event to your tracking service of choice. 128 | dataLayer.push("Section shown"); // Here's a GTM dataLayer push 129 | } 130 | }, { 131 | triggerOnce: true, 132 | rootMargin: "-100px 0", 133 | }); 134 | 135 | return ( 136 |
137 | Exemplars sunt zeluss de bassus fuga. Credere velox ducunt ad audax amor. 138 |
139 | ); 140 | }; 141 | 142 | export default TrackImpression; 143 | ``` 144 | -------------------------------------------------------------------------------- /src/useOnInView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { 3 | IntersectionChangeEffect, 4 | IntersectionEffectOptions, 5 | } from "./index"; 6 | import { observe } from "./observe"; 7 | 8 | const useSyncEffect = 9 | ( 10 | React as typeof React & { 11 | useInsertionEffect?: typeof React.useEffect; 12 | } 13 | ).useInsertionEffect ?? 14 | React.useLayoutEffect ?? 15 | React.useEffect; 16 | 17 | /** 18 | * React Hooks make it easy to monitor when elements come into and leave view. Call 19 | * the `useOnInView` hook with your callback and (optional) [options](#options). 20 | * It will return a ref callback that you can assign to the DOM element you want to monitor. 21 | * When the element enters or leaves the viewport, your callback will be triggered. 22 | * 23 | * This hook triggers no re-renders, and is useful for performance-critical use-cases or 24 | * when you need to trigger render independent side effects like tracking or logging. 25 | * 26 | * @example 27 | * ```jsx 28 | * import React from 'react'; 29 | * import { useOnInView } from 'react-intersection-observer'; 30 | * 31 | * const Component = () => { 32 | * const inViewRef = useOnInView((inView, entry) => { 33 | * if (inView) { 34 | * console.log("Element is in view", entry.target); 35 | * } else { 36 | * console.log("Element left view", entry.target); 37 | * } 38 | * }); 39 | * 40 | * return ( 41 | *
42 | *

This element is being monitored

43 | *
44 | * ); 45 | * }; 46 | * ``` 47 | */ 48 | export const useOnInView = ( 49 | onIntersectionChange: IntersectionChangeEffect, 50 | { 51 | threshold, 52 | root, 53 | rootMargin, 54 | trackVisibility, 55 | delay, 56 | triggerOnce, 57 | skip, 58 | }: IntersectionEffectOptions = {}, 59 | ) => { 60 | const onIntersectionChangeRef = React.useRef(onIntersectionChange); 61 | const observedElementRef = React.useRef(null); 62 | const observerCleanupRef = React.useRef<(() => void) | undefined>(undefined); 63 | const lastInViewRef = React.useRef(undefined); 64 | 65 | useSyncEffect(() => { 66 | onIntersectionChangeRef.current = onIntersectionChange; 67 | }, [onIntersectionChange]); 68 | 69 | // biome-ignore lint/correctness/useExhaustiveDependencies: Threshold arrays are normalized inside the callback 70 | return React.useCallback( 71 | (element: TElement | undefined | null) => { 72 | // React <19 never calls ref callbacks with `null` during unmount, so we 73 | // eagerly tear down existing observers manually whenever the target changes. 74 | const cleanupExisting = () => { 75 | if (observerCleanupRef.current) { 76 | const cleanup = observerCleanupRef.current; 77 | observerCleanupRef.current = undefined; 78 | cleanup(); 79 | } 80 | }; 81 | 82 | if (element === observedElementRef.current) { 83 | return observerCleanupRef.current; 84 | } 85 | 86 | if (!element || skip) { 87 | cleanupExisting(); 88 | observedElementRef.current = null; 89 | lastInViewRef.current = undefined; 90 | return; 91 | } 92 | 93 | cleanupExisting(); 94 | 95 | observedElementRef.current = element; 96 | let destroyed = false; 97 | 98 | const destroyObserver = observe( 99 | element, 100 | (inView, entry) => { 101 | const previousInView = lastInViewRef.current; 102 | lastInViewRef.current = inView; 103 | 104 | // Ignore the very first `false` notification so consumers only hear about actual state changes. 105 | if (previousInView === undefined && !inView) { 106 | return; 107 | } 108 | 109 | onIntersectionChangeRef.current( 110 | inView, 111 | entry as IntersectionObserverEntry & { target: TElement }, 112 | ); 113 | if (triggerOnce && inView) { 114 | stopObserving(); 115 | } 116 | }, 117 | { 118 | threshold, 119 | root, 120 | rootMargin, 121 | trackVisibility, 122 | delay, 123 | } as IntersectionObserverInit, 124 | ); 125 | 126 | function stopObserving() { 127 | // Centralized teardown so both manual destroys and React ref updates share 128 | // the same cleanup path (needed for React versions that never call the ref with `null`). 129 | if (destroyed) return; 130 | destroyed = true; 131 | destroyObserver(); 132 | observedElementRef.current = null; 133 | observerCleanupRef.current = undefined; 134 | lastInViewRef.current = undefined; 135 | } 136 | 137 | observerCleanupRef.current = stopObserving; 138 | 139 | return observerCleanupRef.current; 140 | }, 141 | [ 142 | Array.isArray(threshold) ? threshold.toString() : threshold, 143 | root, 144 | rootMargin, 145 | trackVisibility, 146 | delay, 147 | triggerOnce, 148 | skip, 149 | ], 150 | ); 151 | }; 152 | -------------------------------------------------------------------------------- /src/useInView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { IntersectionOptions, InViewHookResponse } from "./index"; 3 | import { observe } from "./observe"; 4 | 5 | type State = { 6 | inView: boolean; 7 | entry?: IntersectionObserverEntry; 8 | }; 9 | 10 | /** 11 | * React Hooks make it easy to monitor the `inView` state of your components. Call 12 | * the `useInView` hook with the (optional) [options](#options) you need. It will 13 | * return an array containing a `ref`, the `inView` status and the current 14 | * [`entry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry). 15 | * Assign the `ref` to the DOM element you want to monitor, and the hook will 16 | * report the status. 17 | * 18 | * @example 19 | * ```jsx 20 | * import React from 'react'; 21 | * import { useInView } from 'react-intersection-observer'; 22 | * 23 | * const Component = () => { 24 | * const { ref, inView, entry } = useInView({ 25 | * threshold: 0, 26 | * }); 27 | * 28 | * return ( 29 | *
30 | *

{`Header inside viewport ${inView}.`}

31 | *
32 | * ); 33 | * }; 34 | * ``` 35 | */ 36 | export function useInView({ 37 | threshold, 38 | delay, 39 | trackVisibility, 40 | rootMargin, 41 | root, 42 | triggerOnce, 43 | skip, 44 | initialInView, 45 | fallbackInView, 46 | onChange, 47 | }: IntersectionOptions = {}): InViewHookResponse { 48 | const [ref, setRef] = React.useState(null); 49 | const callback = React.useRef(onChange); 50 | const lastInViewRef = React.useRef(initialInView); 51 | const [state, setState] = React.useState({ 52 | inView: !!initialInView, 53 | entry: undefined, 54 | }); 55 | 56 | // Store the onChange callback in a `ref`, so we can access the latest instance 57 | // inside the `useEffect`, but without triggering a rerender. 58 | callback.current = onChange; 59 | 60 | // biome-ignore lint/correctness/useExhaustiveDependencies: threshold is not correctly detected as a dependency 61 | React.useEffect( 62 | () => { 63 | if (lastInViewRef.current === undefined) { 64 | lastInViewRef.current = initialInView; 65 | } 66 | // Ensure we have node ref, and that we shouldn't skip observing 67 | if (skip || !ref) return; 68 | 69 | let unobserve: (() => void) | undefined; 70 | unobserve = observe( 71 | ref, 72 | (inView, entry) => { 73 | const previousInView = lastInViewRef.current; 74 | lastInViewRef.current = inView; 75 | 76 | // Ignore the very first `false` notification so consumers only hear about actual state changes. 77 | if (previousInView === undefined && !inView) { 78 | return; 79 | } 80 | 81 | setState({ 82 | inView, 83 | entry, 84 | }); 85 | if (callback.current) callback.current(inView, entry); 86 | 87 | if (entry.isIntersecting && triggerOnce && unobserve) { 88 | // If it should only trigger once, unobserve the element after it's inView 89 | unobserve(); 90 | unobserve = undefined; 91 | } 92 | }, 93 | { 94 | root, 95 | rootMargin, 96 | threshold, 97 | // @ts-expect-error 98 | trackVisibility, 99 | delay, 100 | }, 101 | fallbackInView, 102 | ); 103 | 104 | return () => { 105 | if (unobserve) { 106 | unobserve(); 107 | } 108 | }; 109 | }, 110 | // We break the rule here, because we aren't including the actual `threshold` variable 111 | // eslint-disable-next-line react-hooks/exhaustive-deps 112 | [ 113 | // If the threshold is an array, convert it to a string, so it won't change between renders. 114 | Array.isArray(threshold) ? threshold.toString() : threshold, 115 | ref, 116 | root, 117 | rootMargin, 118 | triggerOnce, 119 | skip, 120 | trackVisibility, 121 | fallbackInView, 122 | delay, 123 | ], 124 | ); 125 | 126 | const entryTarget = state.entry?.target; 127 | const previousEntryTarget = React.useRef(undefined); 128 | if ( 129 | !ref && 130 | entryTarget && 131 | !triggerOnce && 132 | !skip && 133 | previousEntryTarget.current !== entryTarget 134 | ) { 135 | // If we don't have a node ref, then reset the state (unless the hook is set to only `triggerOnce` or `skip`) 136 | // This ensures we correctly reflect the current state - If you aren't observing anything, then nothing is inView 137 | previousEntryTarget.current = entryTarget; 138 | setState({ 139 | inView: !!initialInView, 140 | entry: undefined, 141 | }); 142 | lastInViewRef.current = initialInView; 143 | } 144 | 145 | const result = [setRef, state.inView, state.entry] as InViewHookResponse; 146 | 147 | // Support object destructuring, by adding the specific values. 148 | result.ref = result[0]; 149 | result.inView = result[1]; 150 | result.entry = result[2]; 151 | 152 | return result; 153 | } 154 | -------------------------------------------------------------------------------- /storybook/stories/useInView.story.tsx: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from "@storybook/react"; 2 | import { motion } from "framer-motion"; 3 | import { type CSSProperties, useEffect, useRef, useState } from "react"; 4 | import { 5 | type IntersectionOptions, 6 | InView, 7 | useInView, 8 | } from "react-intersection-observer"; 9 | import { 10 | EntryDetails, 11 | ErrorMessage, 12 | InViewBlock, 13 | InViewIcon, 14 | RootMargin, 15 | ScrollWrapper, 16 | Status, 17 | ThresholdMarker, 18 | } from "./elements"; 19 | import { argTypes, useValidateOptions } from "./story-utils"; 20 | 21 | type Props = IntersectionOptions & { 22 | style?: CSSProperties; 23 | className?: string; 24 | lazy?: boolean; 25 | inlineRef?: boolean; 26 | }; 27 | 28 | type Story = StoryObj; 29 | 30 | export default { 31 | title: "useInView Hook", 32 | component: InView, 33 | parameters: { 34 | controls: { 35 | expanded: true, 36 | }, 37 | }, 38 | argTypes: { 39 | ...argTypes, 40 | style: { table: { disable: true } }, 41 | className: { table: { disable: true } }, 42 | lazy: { table: { disable: true } }, 43 | inlineRef: { table: { disable: true } }, 44 | }, 45 | args: { 46 | threshold: 0, 47 | }, 48 | render: HooksRender, 49 | } satisfies Meta; 50 | 51 | function HooksRender({ style, className, lazy, inlineRef, ...rest }: Props) { 52 | const { options, error } = useValidateOptions(rest); 53 | const { ref, inView } = useInView(!error ? { ...options } : {}); 54 | const [isLoading, setIsLoading] = useState(lazy); 55 | 56 | useEffect(() => { 57 | if (isLoading) setIsLoading(false); 58 | }, [isLoading]); 59 | 60 | if (error) { 61 | return {error}; 62 | } 63 | 64 | if (isLoading) { 65 | return
Loading...
; 66 | } 67 | 68 | return ( 69 | 70 | 71 | ref(node) : ref} 73 | inView={inView} 74 | style={style} 75 | > 76 | 77 | 78 | 79 | 80 | 81 | 82 | ); 83 | } 84 | 85 | export const Basic: Story = { 86 | args: {}, 87 | }; 88 | 89 | export const LazyHookRendering: Story = { 90 | args: { lazy: true }, 91 | }; 92 | 93 | export const InlineRef: Story = { 94 | args: { 95 | inlineRef: true, 96 | }, 97 | }; 98 | 99 | export const StartInView: Story = { 100 | args: { 101 | initialInView: true, 102 | }, 103 | }; 104 | 105 | export const WithRootMargin: Story = { 106 | args: { 107 | initialInView: true, 108 | rootMargin: "25px 0px", 109 | }, 110 | }; 111 | 112 | export const TallerThanViewport: Story = { 113 | args: { 114 | style: { minHeight: "150vh" }, 115 | }, 116 | }; 117 | 118 | export const WithThreshold100percentage: Story = { 119 | args: { 120 | initialInView: true, 121 | threshold: 1, 122 | }, 123 | }; 124 | 125 | export const WithThreshold50percentage: Story = { 126 | args: { 127 | initialInView: true, 128 | threshold: 0.5, 129 | }, 130 | }; 131 | 132 | export const TallerThanViewportWithThreshold100percentage: Story = { 133 | args: { 134 | threshold: 1, 135 | style: { minHeight: "150vh" }, 136 | }, 137 | }; 138 | 139 | export const MultipleThresholds: Story = { 140 | args: { 141 | threshold: [0, 0.2, 0.4, 0.6, 0.8, 1], 142 | }, 143 | argTypes: { 144 | threshold: { 145 | options: [0, 0.25, 0.5, 0.75, 1], 146 | control: { type: "multi-select" }, 147 | }, 148 | }, 149 | }; 150 | 151 | export const TriggerOnce: Story = { 152 | args: { 153 | triggerOnce: true, 154 | }, 155 | }; 156 | 157 | export const Skip: Story = { 158 | args: { 159 | initialInView: true, 160 | skip: true, 161 | }, 162 | }; 163 | 164 | const VisibilityTemplate = (props: Props) => { 165 | const { options, error } = useValidateOptions(props); 166 | const ref = useRef(null); 167 | const { entry, inView, ref: inViewRef } = useInView(options); 168 | 169 | if (error) { 170 | return {error}; 171 | } 172 | 173 | return ( 174 |
175 |
176 |

Track Visibility

177 |

178 | Use the new IntersectionObserver v2 to track if the object is visible. 179 | Try dragging the box on top of it. If the feature is unsupported, it 180 | will always return `isVisible`. 181 |

182 | 188 | Drag me 189 | 190 |
191 | 192 | {/* @ts-ignore */} 193 | 194 | 195 | 196 |
197 | ); 198 | }; 199 | 200 | export const TrackVisibility: Story = { 201 | render: VisibilityTemplate, 202 | args: { 203 | trackVisibility: true, 204 | delay: 100, 205 | }, 206 | }; 207 | -------------------------------------------------------------------------------- /src/observe.ts: -------------------------------------------------------------------------------- 1 | import type { ObserverInstanceCallback } from "./index"; 2 | 3 | const observerMap = new Map< 4 | string, 5 | { 6 | id: string; 7 | observer: IntersectionObserver; 8 | elements: Map>; 9 | } 10 | >(); 11 | 12 | const RootIds: WeakMap = new WeakMap(); 13 | let rootId = 0; 14 | 15 | let unsupportedValue: boolean | undefined; 16 | 17 | /** 18 | * What should be the default behavior if the IntersectionObserver is unsupported? 19 | * Ideally the polyfill has been loaded, you can have the following happen: 20 | * - `undefined`: Throw an error 21 | * - `true` or `false`: Set the `inView` value to this regardless of intersection state 22 | * **/ 23 | export function defaultFallbackInView(inView: boolean | undefined) { 24 | unsupportedValue = inView; 25 | } 26 | 27 | /** 28 | * Generate a unique ID for the root element 29 | * @param root 30 | */ 31 | function getRootId(root: IntersectionObserverInit["root"]) { 32 | if (!root) return "0"; 33 | if (RootIds.has(root)) return RootIds.get(root); 34 | rootId += 1; 35 | RootIds.set(root, rootId.toString()); 36 | return RootIds.get(root); 37 | } 38 | 39 | /** 40 | * Convert the options to a string Id, based on the values. 41 | * Ensures we can reuse the same observer when observing elements with the same options. 42 | * @param options 43 | */ 44 | export function optionsToId(options: IntersectionObserverInit) { 45 | return Object.keys(options) 46 | .sort() 47 | .filter( 48 | (key) => options[key as keyof IntersectionObserverInit] !== undefined, 49 | ) 50 | .map((key) => { 51 | return `${key}_${ 52 | key === "root" 53 | ? getRootId(options.root) 54 | : options[key as keyof IntersectionObserverInit] 55 | }`; 56 | }) 57 | .toString(); 58 | } 59 | 60 | function createObserver(options: IntersectionObserverInit) { 61 | // Create a unique ID for this observer instance, based on the root, root margin and threshold. 62 | const id = optionsToId(options); 63 | let instance = observerMap.get(id); 64 | 65 | if (!instance) { 66 | // Create a map of elements this observer is going to observe. Each element has a list of callbacks that should be triggered, once it comes into view. 67 | const elements = new Map>(); 68 | let thresholds: number[] | readonly number[]; 69 | 70 | const observer = new IntersectionObserver((entries) => { 71 | entries.forEach((entry) => { 72 | // While it would be nice if you could just look at isIntersecting to determine if the component is inside the viewport, browsers can't agree on how to use it. 73 | // -Firefox ignores `threshold` when considering `isIntersecting`, so it will never be false again if `threshold` is > 0 74 | const inView = 75 | entry.isIntersecting && 76 | thresholds.some((threshold) => entry.intersectionRatio >= threshold); 77 | 78 | // @ts-expect-error support IntersectionObserver v2 79 | if (options.trackVisibility && typeof entry.isVisible === "undefined") { 80 | // The browser doesn't support Intersection Observer v2, falling back to v1 behavior. 81 | // @ts-expect-error 82 | entry.isVisible = inView; 83 | } 84 | 85 | elements.get(entry.target)?.forEach((callback) => { 86 | callback(inView, entry); 87 | }); 88 | }); 89 | }, options); 90 | 91 | // Ensure we have a valid thresholds array. If not, use the threshold from the options 92 | thresholds = 93 | observer.thresholds || 94 | (Array.isArray(options.threshold) 95 | ? options.threshold 96 | : [options.threshold || 0]); 97 | 98 | instance = { 99 | id, 100 | observer, 101 | elements, 102 | }; 103 | 104 | observerMap.set(id, instance); 105 | } 106 | 107 | return instance; 108 | } 109 | 110 | /** 111 | * @param element - DOM Element to observe 112 | * @param callback - Callback function to trigger when intersection status changes 113 | * @param options - Intersection Observer options 114 | * @param fallbackInView - Fallback inView value. 115 | * @return Function - Cleanup function that should be triggered to unregister the observer 116 | */ 117 | export function observe( 118 | element: Element, 119 | callback: ObserverInstanceCallback, 120 | options: IntersectionObserverInit = {}, 121 | fallbackInView = unsupportedValue, 122 | ) { 123 | if ( 124 | typeof window.IntersectionObserver === "undefined" && 125 | fallbackInView !== undefined 126 | ) { 127 | const bounds = element.getBoundingClientRect(); 128 | callback(fallbackInView, { 129 | isIntersecting: fallbackInView, 130 | target: element, 131 | intersectionRatio: 132 | typeof options.threshold === "number" ? options.threshold : 0, 133 | time: 0, 134 | boundingClientRect: bounds, 135 | intersectionRect: bounds, 136 | rootBounds: bounds, 137 | }); 138 | return () => { 139 | // Nothing to cleanup 140 | }; 141 | } 142 | // An observer with the same options can be reused, so lets use this fact 143 | const { id, observer, elements } = createObserver(options); 144 | 145 | // Register the callback listener for this element 146 | const callbacks = elements.get(element) || []; 147 | if (!elements.has(element)) { 148 | elements.set(element, callbacks); 149 | } 150 | 151 | callbacks.push(callback); 152 | observer.observe(element); 153 | 154 | return function unobserve() { 155 | // Remove the callback from the callback list 156 | callbacks.splice(callbacks.indexOf(callback), 1); 157 | 158 | if (callbacks.length === 0) { 159 | // No more callback exists for element, so destroy it 160 | elements.delete(element); 161 | observer.unobserve(element); 162 | } 163 | 164 | if (elements.size === 0) { 165 | // No more elements are being observer by this instance, so destroy it 166 | observer.disconnect(); 167 | observerMap.delete(id); 168 | } 169 | }; 170 | } 171 | -------------------------------------------------------------------------------- /src/InView.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import type { IntersectionObserverProps, PlainChildrenProps } from "./index"; 3 | import { observe } from "./observe"; 4 | 5 | type State = { 6 | inView: boolean; 7 | entry?: IntersectionObserverEntry; 8 | }; 9 | 10 | function isPlainChildren( 11 | props: IntersectionObserverProps | PlainChildrenProps, 12 | ): props is PlainChildrenProps { 13 | return typeof props.children !== "function"; 14 | } 15 | 16 | /** 17 | ## Render props 18 | 19 | To use the `` component, you pass it a function. It will be called 20 | whenever the state changes, with the new value of `inView`. In addition to the 21 | `inView` prop, children also receive a `ref` that should be set on the 22 | containing DOM element. This is the element that the IntersectionObserver will 23 | monitor. 24 | 25 | If you need it, you can also access the 26 | [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) 27 | on `entry`, giving you access to all the details about the current intersection 28 | state. 29 | 30 | ```jsx 31 | import { InView } from 'react-intersection-observer'; 32 | 33 | const Component = () => ( 34 | 35 | {({ inView, ref, entry }) => ( 36 |
37 |

{`Header inside viewport ${inView}.`}

38 |
39 | )} 40 |
41 | ); 42 | 43 | export default Component; 44 | ``` 45 | 46 | ## Plain children 47 | 48 | You can pass any element to the ``, and it will handle creating the 49 | wrapping DOM element. Add a handler to the `onChange` method, and control the 50 | state in your own component. Any extra props you add to `` will be 51 | passed to the HTML element, allowing you set the `className`, `style`, etc. 52 | 53 | ```jsx 54 | import { InView } from 'react-intersection-observer'; 55 | 56 | const Component = () => ( 57 | console.log('Inview:', inView)}> 58 |

Plain children are always rendered. Use onChange to monitor state.

59 |
60 | ); 61 | 62 | export default Component; 63 | ``` 64 | */ 65 | export class InView extends React.Component< 66 | IntersectionObserverProps | PlainChildrenProps, 67 | State 68 | > { 69 | node: Element | null = null; 70 | _unobserveCb: (() => void) | null = null; 71 | lastInView: boolean | undefined; 72 | 73 | constructor(props: IntersectionObserverProps | PlainChildrenProps) { 74 | super(props); 75 | this.state = { 76 | inView: !!props.initialInView, 77 | entry: undefined, 78 | }; 79 | this.lastInView = props.initialInView; 80 | } 81 | 82 | componentDidMount() { 83 | this.unobserve(); 84 | this.observeNode(); 85 | } 86 | 87 | componentDidUpdate(prevProps: IntersectionObserverProps) { 88 | // If a IntersectionObserver option changed, reinit the observer 89 | if ( 90 | prevProps.rootMargin !== this.props.rootMargin || 91 | prevProps.root !== this.props.root || 92 | prevProps.threshold !== this.props.threshold || 93 | prevProps.skip !== this.props.skip || 94 | prevProps.trackVisibility !== this.props.trackVisibility || 95 | prevProps.delay !== this.props.delay 96 | ) { 97 | this.unobserve(); 98 | this.observeNode(); 99 | } 100 | } 101 | 102 | componentWillUnmount() { 103 | this.unobserve(); 104 | } 105 | 106 | observeNode() { 107 | if (!this.node || this.props.skip) return; 108 | const { 109 | threshold, 110 | root, 111 | rootMargin, 112 | trackVisibility, 113 | delay, 114 | fallbackInView, 115 | } = this.props; 116 | 117 | if (this.lastInView === undefined) { 118 | this.lastInView = this.props.initialInView; 119 | } 120 | this._unobserveCb = observe( 121 | this.node, 122 | this.handleChange, 123 | { 124 | threshold, 125 | root, 126 | rootMargin, 127 | // @ts-expect-error 128 | trackVisibility, 129 | delay, 130 | }, 131 | fallbackInView, 132 | ); 133 | } 134 | 135 | unobserve() { 136 | if (this._unobserveCb) { 137 | this._unobserveCb(); 138 | this._unobserveCb = null; 139 | } 140 | } 141 | 142 | handleNode = (node?: Element | null) => { 143 | if (this.node) { 144 | // Clear the old observer, before we start observing a new element 145 | this.unobserve(); 146 | 147 | if (!node && !this.props.triggerOnce && !this.props.skip) { 148 | // Reset the state if we get a new node, and we aren't ignoring updates 149 | this.setState({ inView: !!this.props.initialInView, entry: undefined }); 150 | this.lastInView = this.props.initialInView; 151 | } 152 | } 153 | 154 | this.node = node ? node : null; 155 | this.observeNode(); 156 | }; 157 | 158 | handleChange = (inView: boolean, entry: IntersectionObserverEntry) => { 159 | const previousInView = this.lastInView; 160 | this.lastInView = inView; 161 | 162 | // Ignore the very first `false` notification so consumers only hear about actual state changes. 163 | if (previousInView === undefined && !inView) { 164 | return; 165 | } 166 | 167 | if (inView && this.props.triggerOnce) { 168 | // If `triggerOnce` is true, we should stop observing the element. 169 | this.unobserve(); 170 | } 171 | if (!isPlainChildren(this.props)) { 172 | // Store the current State, so we can pass it to the children in the next render update 173 | // There's no reason to update the state for plain children, since it's not used in the rendering. 174 | this.setState({ inView, entry }); 175 | } 176 | if (this.props.onChange) { 177 | // If the user is actively listening for onChange, always trigger it 178 | this.props.onChange(inView, entry); 179 | } 180 | }; 181 | 182 | render() { 183 | const { children } = this.props; 184 | if (typeof children === "function") { 185 | const { inView, entry } = this.state; 186 | return children({ inView, entry, ref: this.handleNode }); 187 | } 188 | 189 | const { 190 | as, 191 | triggerOnce, 192 | threshold, 193 | root, 194 | rootMargin, 195 | onChange, 196 | skip, 197 | trackVisibility, 198 | delay, 199 | initialInView, 200 | fallbackInView, 201 | ...props 202 | } = this.props as PlainChildrenProps; 203 | 204 | return React.createElement( 205 | as || "div", 206 | { ref: this.handleNode, ...props }, 207 | children, 208 | ); 209 | } 210 | } 211 | -------------------------------------------------------------------------------- /src/__tests__/InView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import { userEvent } from "vitest/browser"; 3 | import { InView } from "../InView"; 4 | import { defaultFallbackInView } from "../observe"; 5 | import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; 6 | 7 | test("Should render intersecting", () => { 8 | const callback = vi.fn(); 9 | render( 10 | 11 | {({ inView, ref }) =>
{inView.toString()}
} 12 |
, 13 | ); 14 | 15 | mockAllIsIntersecting(false); 16 | expect(callback).not.toHaveBeenCalled(); 17 | 18 | mockAllIsIntersecting(true); 19 | expect(callback).toHaveBeenLastCalledWith( 20 | true, 21 | expect.objectContaining({ isIntersecting: true }), 22 | ); 23 | 24 | mockAllIsIntersecting(false); 25 | expect(callback).toHaveBeenLastCalledWith( 26 | false, 27 | expect.objectContaining({ isIntersecting: false }), 28 | ); 29 | }); 30 | 31 | test("should render plain children", () => { 32 | render(inner); 33 | screen.getByText("inner"); 34 | }); 35 | 36 | test("should render as element", () => { 37 | const { container } = render(inner); 38 | const tagName = container.children[0].tagName.toLowerCase(); 39 | expect(tagName).toBe("span"); 40 | }); 41 | 42 | test("should render with className", () => { 43 | const { container } = render(inner); 44 | expect(container.children[0].className).toBe("inner-class"); 45 | }); 46 | 47 | test("Should respect skip", () => { 48 | const cb = vi.fn(); 49 | render( 50 | 51 | inner 52 | , 53 | ); 54 | mockAllIsIntersecting(true); 55 | 56 | expect(cb).not.toHaveBeenCalled(); 57 | }); 58 | 59 | test("Should handle initialInView", () => { 60 | const cb = vi.fn(); 61 | render( 62 | 63 | {({ inView }) => InView: {inView.toString()}} 64 | , 65 | ); 66 | screen.getByText("InView: true"); 67 | }); 68 | 69 | test("Should unobserve old node", () => { 70 | const { rerender } = render( 71 | 72 | {({ inView, ref }) => ( 73 |
74 | Inview: {inView.toString()} 75 |
76 | )} 77 |
, 78 | ); 79 | rerender( 80 | 81 | {({ inView, ref }) => ( 82 |
83 | Inview: {inView.toString()} 84 |
85 | )} 86 |
, 87 | ); 88 | mockAllIsIntersecting(true); 89 | }); 90 | 91 | test("Should ensure node exists before observing and unobserving", () => { 92 | const { unmount } = render({() => null}); 93 | unmount(); 94 | }); 95 | 96 | test("Should recreate observer when threshold change", () => { 97 | const { container, rerender } = render(Inner); 98 | mockAllIsIntersecting(true); 99 | const instance = intersectionMockInstance(container.children[0]); 100 | vi.spyOn(instance, "unobserve"); 101 | 102 | rerender(Inner); 103 | expect(instance.unobserve).toHaveBeenCalled(); 104 | }); 105 | 106 | test("Should recreate observer when root change", () => { 107 | const { container, rerender } = render(Inner); 108 | mockAllIsIntersecting(true); 109 | const instance = intersectionMockInstance(container.children[0]); 110 | vi.spyOn(instance, "unobserve"); 111 | 112 | const root = document.createElement("div"); 113 | rerender(Inner); 114 | expect(instance.unobserve).toHaveBeenCalled(); 115 | }); 116 | 117 | test("Should recreate observer when rootMargin change", () => { 118 | const { container, rerender } = render(Inner); 119 | mockAllIsIntersecting(true); 120 | const instance = intersectionMockInstance(container.children[0]); 121 | vi.spyOn(instance, "unobserve"); 122 | 123 | rerender(Inner); 124 | expect(instance.unobserve).toHaveBeenCalled(); 125 | }); 126 | 127 | test("Should unobserve when triggerOnce comes into view", () => { 128 | const { container } = render(Inner); 129 | mockAllIsIntersecting(false); 130 | const instance = intersectionMockInstance(container.children[0]); 131 | vi.spyOn(instance, "unobserve"); 132 | mockAllIsIntersecting(true); 133 | 134 | expect(instance.unobserve).toHaveBeenCalled(); 135 | }); 136 | 137 | test("Should unobserve when unmounted", () => { 138 | const { container, unmount } = render(Inner); 139 | const instance = intersectionMockInstance(container.children[0]); 140 | 141 | vi.spyOn(instance, "unobserve"); 142 | 143 | unmount(); 144 | 145 | expect(instance.unobserve).toHaveBeenCalled(); 146 | }); 147 | 148 | test("plain children should not catch bubbling onChange event", async () => { 149 | const onChange = vi.fn(); 150 | const { getByLabelText } = render( 151 | 152 | 156 | , 157 | ); 158 | const input = getByLabelText("input"); 159 | await userEvent.type(input, "changed value"); 160 | expect(onChange).not.toHaveBeenCalled(); 161 | }); 162 | 163 | test("should render with fallback", () => { 164 | const cb = vi.fn(); 165 | // @ts-expect-error 166 | window.IntersectionObserver = undefined; 167 | render( 168 | 169 | Inner 170 | , 171 | ); 172 | expect(cb).toHaveBeenLastCalledWith( 173 | true, 174 | expect.objectContaining({ isIntersecting: true }), 175 | ); 176 | 177 | render( 178 | 179 | Inner 180 | , 181 | ); 182 | expect(cb).toHaveBeenLastCalledWith( 183 | false, 184 | expect.objectContaining({ isIntersecting: false }), 185 | ); 186 | 187 | expect(() => { 188 | vi.spyOn(console, "error").mockImplementation(() => {}); 189 | render(Inner); 190 | // @ts-expect-error 191 | console.error.mockRestore(); 192 | }).toThrow(); 193 | }); 194 | 195 | test("should render with global fallback", () => { 196 | const cb = vi.fn(); 197 | // @ts-expect-error 198 | window.IntersectionObserver = undefined; 199 | defaultFallbackInView(true); 200 | render(Inner); 201 | expect(cb).toHaveBeenLastCalledWith( 202 | true, 203 | expect.objectContaining({ isIntersecting: true }), 204 | ); 205 | 206 | defaultFallbackInView(false); 207 | render(Inner); 208 | expect(cb).toHaveBeenLastCalledWith( 209 | false, 210 | expect.objectContaining({ isIntersecting: false }), 211 | ); 212 | 213 | defaultFallbackInView(undefined); 214 | expect(() => { 215 | vi.spyOn(console, "error").mockImplementation(() => {}); 216 | render(Inner); 217 | // @ts-expect-error 218 | console.error.mockRestore(); 219 | }).toThrow(); 220 | }); 221 | -------------------------------------------------------------------------------- /storybook/stories/elements.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import React, { useEffect, useRef, useState } from "react"; 3 | import type { IntersectionOptions } from "react-intersection-observer"; 4 | 5 | type ScrollProps = { 6 | children: React.ReactNode; 7 | indicators?: "all" | "top" | "bottom" | "none"; 8 | }; 9 | 10 | export function ErrorMessage({ children }: { children?: React.ReactNode }) { 11 | return ( 12 |
13 |
14 |

Invalid options

15 | {children} 16 |
17 |
18 | ); 19 | } 20 | 21 | /** 22 | * ScrollWrapper directs the user to scroll the page to reveal it's children. 23 | * Use this on Modules that have scroll and/or observer triggers. 24 | */ 25 | export function ScrollWrapper({ 26 | children, 27 | indicators = "all", 28 | ...props 29 | }: ScrollProps) { 30 | return ( 31 |
32 | {indicators === "top" || indicators === "all" ? ( 33 |
34 |

Scroll down

35 | 41 | 47 | 48 |
49 | ) : null} 50 |
{children}
51 | {indicators === "bottom" || indicators === "all" ? ( 52 |
56 | 62 | 68 | 69 |
70 | ) : null} 71 |
72 | ); 73 | } 74 | 75 | export const InViewBlock = React.forwardRef< 76 | HTMLDivElement, 77 | React.HTMLProps & { inView: boolean } 78 | >(({ className, inView, ...rest }, ref) => ( 79 |
85 | )); 86 | 87 | export function InViewIcon({ inView }: { inView: boolean }) { 88 | return ( 89 | 90 | 101 | 107 | {inView ? ( 108 | 114 | ) : ( 115 | 121 | )} 122 | 123 | 124 | ) 125 | 126 | ); 127 | } 128 | 129 | export function Status({ inView }: { inView: boolean }) { 130 | return ( 131 |
137 | 138 | InView: {inView.toString()} 139 | 140 | 141 | {inView ? ( 142 | 148 | 154 | 155 | ) : ( 156 | 162 | 168 | 169 | )} 170 | 171 |
172 | ); 173 | } 174 | 175 | export function RootMargin({ rootMargin }: { rootMargin?: string }) { 176 | if (!rootMargin) return null; 177 | // Invert the root margin, so it correctly renders the outline 178 | const invertedRootMargin = rootMargin 179 | .split(" ") 180 | .map((val) => (val.charAt(0) === "-" ? val.substr(1) : `-${val}`)) 181 | .join(" "); 182 | 183 | return ( 184 |
188 | ); 189 | } 190 | 191 | export function ThresholdMarker({ 192 | threshold = 0, 193 | }: { 194 | threshold?: number | number[]; 195 | }) { 196 | const values = Array.isArray(threshold) ? threshold : [threshold]; 197 | return ( 198 | <> 199 | {values.map((value) => { 200 | return ( 201 |
202 |
209 |
216 |
223 |
230 |
231 | ); 232 | })} 233 | 234 | ); 235 | } 236 | 237 | export function EntryDetails({ options }: { options?: IntersectionOptions }) { 238 | if (!options || !Object.keys(options).length) return null; 239 | const value = JSON.stringify( 240 | { ...options, root: options.root ? "Element" : undefined }, 241 | null, 242 | 2, 243 | ); 244 | if (value === "{}") return null; 245 | 246 | return ( 247 |
248 |       {value}
249 |     
250 | ); 251 | } 252 | 253 | type RootProps = { 254 | children: (node: HTMLDivElement) => React.ReactNode; 255 | }; 256 | 257 | export function RootComponent(props: RootProps) { 258 | const node = useRef(null); 259 | const [, setMounted] = useState(false); 260 | useEffect(() => { 261 | setMounted(true); 262 | }, []); 263 | 264 | return ( 265 |
266 |
267 | {node.current ? props.children(node.current) : null} 268 |
269 |
270 | ); 271 | } 272 | -------------------------------------------------------------------------------- /src/test-utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import * as DeprecatedReactTestUtils from "react-dom/test-utils"; 3 | 4 | type Item = { 5 | callback: IntersectionObserverCallback; 6 | elements: Set; 7 | created: number; 8 | }; 9 | 10 | const observers = new Map(); 11 | 12 | // Store a reference to the original `IntersectionObserver` so we can restore it later. 13 | // This can be relevant if testing in a browser environment, where you actually have a native `IntersectionObserver`. 14 | const originalIntersectionObserver = 15 | typeof window !== "undefined" ? window.IntersectionObserver : undefined; 16 | 17 | /** 18 | * Get the test utility object, depending on the environment. This could be either `vi` (Vitest) or `jest`. 19 | * Type is mapped to Vitest, so we don't mix in Jest types when running in Vitest. 20 | */ 21 | function testLibraryUtil(): typeof vi | undefined { 22 | if (typeof vi !== "undefined") return vi; 23 | // @ts-expect-error We don't include the Jest types 24 | if (typeof jest !== "undefined") return jest; 25 | return undefined; 26 | } 27 | 28 | /** 29 | * Check if the IntersectionObserver is currently being mocked. 30 | * @return boolean 31 | */ 32 | function isMocking() { 33 | const util = testLibraryUtil(); 34 | if (util && typeof util.isMockFunction === "function") { 35 | return util.isMockFunction(window.IntersectionObserver); 36 | } 37 | 38 | // No global test utility found. Check if the IntersectionObserver was manually mocked. 39 | if ( 40 | typeof window !== "undefined" && 41 | window.IntersectionObserver && 42 | "mockClear" in window.IntersectionObserver 43 | ) { 44 | return true; 45 | } 46 | 47 | return false; 48 | } 49 | 50 | /* 51 | ** If we are running in a valid testing environment, we can automate mocking the IntersectionObserver. 52 | */ 53 | if ( 54 | typeof window !== "undefined" && 55 | typeof beforeEach !== "undefined" && 56 | typeof afterEach !== "undefined" 57 | ) { 58 | beforeEach(() => { 59 | const util = testLibraryUtil(); 60 | if (util) { 61 | setupIntersectionMocking(util.fn); 62 | } 63 | // Ensure there's no observers from previous tests 64 | observers.clear(); 65 | }); 66 | 67 | afterEach(resetIntersectionMocking); 68 | } 69 | 70 | function getActFn() { 71 | if ( 72 | !( 73 | typeof window !== "undefined" && 74 | // @ts-expect-error 75 | window.IS_REACT_ACT_ENVIRONMENT 76 | ) 77 | ) { 78 | return undefined; 79 | } 80 | // biome-ignore lint/suspicious/noTsIgnore: Needed for compatibility with multiple React versions 81 | // @ts-ignore 82 | return typeof React.act === "function" 83 | ? // @ts-ignore 84 | React.act 85 | : DeprecatedReactTestUtils.act; 86 | } 87 | 88 | function warnOnMissingSetup() { 89 | if (isMocking()) return; 90 | console.error( 91 | `React Intersection Observer was not configured to handle mocking. 92 | Outside Jest and Vitest, you might need to manually configure it by calling setupIntersectionMocking() and resetIntersectionMocking() in your test setup file. 93 | 94 | // test-setup.js 95 | import { resetIntersectionMocking, setupIntersectionMocking } from 'react-intersection-observer/test-utils'; 96 | 97 | beforeEach(() => { 98 | setupIntersectionMocking(vi.fn); 99 | }); 100 | 101 | afterEach(() => { 102 | resetIntersectionMocking(); 103 | });`, 104 | ); 105 | } 106 | 107 | /** 108 | * Create a custom IntersectionObserver mock, allowing us to intercept the `observe` and `unobserve` calls. 109 | * We keep track of the elements being observed, so when `mockAllIsIntersecting` is triggered it will 110 | * know which elements to trigger the event on. 111 | * @param mockFn The mock function to use. Defaults to `vi.fn`. 112 | */ 113 | export function setupIntersectionMocking(mockFn: typeof vi.fn) { 114 | window.IntersectionObserver = mockFn(function IntersectionObserverMock( 115 | this: IntersectionObserver, 116 | cb, 117 | options = {}, 118 | ) { 119 | const item = { 120 | callback: cb, 121 | elements: new Set(), 122 | created: Date.now(), 123 | }; 124 | const instance: IntersectionObserver = { 125 | thresholds: Array.isArray(options.threshold) 126 | ? options.threshold 127 | : [options.threshold ?? 0], 128 | root: options.root ?? null, 129 | rootMargin: options.rootMargin ?? "", 130 | observe: mockFn((element: Element) => { 131 | item.elements.add(element); 132 | }), 133 | unobserve: mockFn((element: Element) => { 134 | item.elements.delete(element); 135 | }), 136 | disconnect: mockFn(() => { 137 | observers.delete(instance); 138 | }), 139 | takeRecords: mockFn(), 140 | }; 141 | 142 | observers.set(instance, item); 143 | 144 | return instance; 145 | }); 146 | } 147 | 148 | /** 149 | * Reset the IntersectionObserver mock to its initial state, and clear all the elements being observed. 150 | */ 151 | export function resetIntersectionMocking() { 152 | if ( 153 | window.IntersectionObserver && 154 | "mockClear" in window.IntersectionObserver && 155 | typeof window.IntersectionObserver.mockClear === "function" 156 | ) { 157 | window.IntersectionObserver.mockClear(); 158 | } 159 | observers.clear(); 160 | } 161 | 162 | /** 163 | * Destroy the IntersectionObserver mock function, and restore the original browser implementation of `IntersectionObserver`. 164 | * You can use this to opt of mocking in a specific test. 165 | **/ 166 | export function destroyIntersectionMocking() { 167 | resetIntersectionMocking(); 168 | // @ts-expect-error 169 | window.IntersectionObserver = originalIntersectionObserver; 170 | } 171 | 172 | function triggerIntersection( 173 | elements: Element[], 174 | trigger: boolean | number, 175 | observer: IntersectionObserver, 176 | item: Item, 177 | ) { 178 | const entries: IntersectionObserverEntry[] = []; 179 | 180 | const isIntersecting = 181 | typeof trigger === "number" 182 | ? observer.thresholds.some((threshold) => trigger >= threshold) 183 | : trigger; 184 | 185 | let ratio: number; 186 | 187 | if (typeof trigger === "number") { 188 | const intersectedThresholds = observer.thresholds.filter( 189 | (threshold) => trigger >= threshold, 190 | ); 191 | ratio = 192 | intersectedThresholds.length > 0 193 | ? intersectedThresholds[intersectedThresholds.length - 1] 194 | : 0; 195 | } else { 196 | ratio = trigger ? 1 : 0; 197 | } 198 | 199 | for (const element of elements) { 200 | entries.push({ 201 | boundingClientRect: element.getBoundingClientRect(), 202 | intersectionRatio: ratio, 203 | intersectionRect: isIntersecting 204 | ? element.getBoundingClientRect() 205 | : { 206 | bottom: 0, 207 | height: 0, 208 | left: 0, 209 | right: 0, 210 | top: 0, 211 | width: 0, 212 | x: 0, 213 | y: 0, 214 | toJSON() {}, 215 | }, 216 | isIntersecting, 217 | rootBounds: 218 | observer.root instanceof Element 219 | ? observer.root?.getBoundingClientRect() 220 | : null, 221 | target: element, 222 | time: Date.now() - item.created, 223 | }); 224 | } 225 | 226 | // Trigger the IntersectionObserver callback with all the entries 227 | const act = getActFn(); 228 | if (act) act(() => item.callback(entries, observer)); 229 | else item.callback(entries, observer); 230 | } 231 | /** 232 | * Set the `isIntersecting` on all current IntersectionObserver instances 233 | * @param isIntersecting {boolean | number} 234 | */ 235 | export function mockAllIsIntersecting(isIntersecting: boolean | number) { 236 | warnOnMissingSetup(); 237 | for (const [observer, item] of observers) { 238 | triggerIntersection( 239 | Array.from(item.elements), 240 | isIntersecting, 241 | observer, 242 | item, 243 | ); 244 | } 245 | } 246 | 247 | /** 248 | * Set the `isIntersecting` for the IntersectionObserver of a specific element. 249 | * 250 | * @param element {Element} 251 | * @param isIntersecting {boolean | number} 252 | */ 253 | export function mockIsIntersecting( 254 | element: Element, 255 | isIntersecting: boolean | number, 256 | ) { 257 | warnOnMissingSetup(); 258 | const observer = intersectionMockInstance(element); 259 | if (!observer) { 260 | throw new Error( 261 | "No IntersectionObserver instance found for element. Is it still mounted in the DOM?", 262 | ); 263 | } 264 | const item = observers.get(observer); 265 | if (item) { 266 | triggerIntersection([element], isIntersecting, observer, item); 267 | } 268 | } 269 | 270 | /** 271 | * Call the `intersectionMockInstance` method with an element, to get the (mocked) 272 | * `IntersectionObserver` instance. You can use this to spy on the `observe` and 273 | * `unobserve` methods. 274 | * @param element {Element} 275 | * @return IntersectionObserver 276 | */ 277 | export function intersectionMockInstance( 278 | element: Element, 279 | ): IntersectionObserver { 280 | warnOnMissingSetup(); 281 | for (const [observer, item] of observers) { 282 | if (item.elements.has(element)) { 283 | return observer; 284 | } 285 | } 286 | 287 | throw new Error( 288 | "Failed to find IntersectionObserver for element. Is it being observed?", 289 | ); 290 | } 291 | -------------------------------------------------------------------------------- /storybook/readme.md: -------------------------------------------------------------------------------- 1 | # react-intersection-observer 2 | 3 | React implementation of the 4 | [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) 5 | to tell you when an element enters or leaves the viewport. 6 | 7 | Contains both a [Hooks](#useinview), [render props](#render-props) and 8 | [plain children](#plain-children) implementation. 9 | 10 | ## Storybook demo 11 | 12 | This Storybook is a collection of examples. The examples are used during 13 | development as a way to validate that all features are working as intended. 14 | 15 | ## Usage 16 | 17 | ### `useInView` 18 | 19 | React Hooks make it easy to monitor the `inView` state of your components. Call 20 | the `useInView` hook with the (optional) [options](#options) you need. It will 21 | return an array containing a `ref`, the `inView` status and the current 22 | [`entry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry). 23 | Assign the `ref` to the DOM element you want to monitor, and the hook will 24 | report the status. 25 | 26 | ```jsx 27 | import React from 'react'; 28 | import { useInView } from 'react-intersection-observer'; 29 | 30 | const Component = () => { 31 | const { ref, inView, entry } = useInView({ 32 | /* Optional options */ 33 | threshold: 0, 34 | }); 35 | 36 | return ( 37 |
38 |

{`Header inside viewport ${inView}.`}

39 |
40 | ); 41 | }; 42 | ``` 43 | 44 | [![Edit useInView](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/useinview-ud2vo?fontsize=14&hidenavigation=1&theme=dark) 45 | 46 | ### Render props 47 | 48 | To use the `` component, you pass it a function. It will be called 49 | whenever the state changes, with the new value of `inView`. In addition to the 50 | `inView` prop, children also receive a `ref` that should be set on the 51 | containing DOM element. This is the element that the IntersectionObserver will 52 | monitor. 53 | 54 | If you need it, you can also access the 55 | [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) 56 | on `entry`, giving you access to all the details about the current intersection 57 | state. 58 | 59 | ```jsx 60 | import { InView } from 'react-intersection-observer'; 61 | 62 | const Component = () => ( 63 | 64 | {({ inView, ref, entry }) => ( 65 |
66 |

{`Header inside viewport ${inView}.`}

67 |
68 | )} 69 |
70 | ); 71 | 72 | export default Component; 73 | ``` 74 | 75 | [![Edit InView render props](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/inview-render-props-hvhcb?fontsize=14&hidenavigation=1&theme=dark) 76 | 77 | ### Plain children 78 | 79 | You can pass any element to the ``, and it will handle creating the 80 | wrapping DOM element. Add a handler to the `onChange` method, and control the 81 | state in your own component. Any extra props you add to `` will be 82 | passed to the HTML element, allowing you set the `className`, `style`, etc. 83 | 84 | ```jsx 85 | import { InView } from 'react-intersection-observer'; 86 | 87 | const Component = () => ( 88 | console.log('Inview:', inView)}> 89 |

Plain children are always rendered. Use onChange to monitor state.

90 |
91 | ); 92 | 93 | export default Component; 94 | ``` 95 | 96 | [![Edit InView plain children](https://codesandbox.io/static/img/play-codesandbox.svg)](https://codesandbox.io/s/inview-plain-children-vv51y?fontsize=14&hidenavigation=1&theme=dark) 97 | 98 | > ⚠️ When rendering a plain child, make sure you keep your HTML output semantic. 99 | > Change the `as` to match the context, and add a `className` to style the 100 | > ``. The component does not support Ref Forwarding, so if you need a 101 | > `ref` to the HTML element, use the Render Props version instead. 102 | 103 | ## API 104 | 105 | ### Options 106 | 107 | Provide these as props on the **``** component or as the options 108 | argument for the hooks. 109 | 110 | | Name | Type | Default | Required | Description | 111 | | ---------------------- | ------------------ | --------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 112 | | **root** | Element | document | false | The IntersectionObserver interface's read-only root property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the root is null, then the bounds of the actual document viewport are used. | 113 | | **rootMargin** | string | '0px' | false | Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). | 114 | | **threshold** | number \| number[] | 0 | false | Number between 0 and 1 indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. | 115 | | **trackVisibility** 🧪 | boolean | false | false | A boolean indicating whether this IntersectionObserver will track changes in a target’s visibility. | 116 | | **delay** 🧪 | number | undefined | false | A number indicating the minimum delay in milliseconds between notifications from this observer for a given target. This must be set to at least `100` if `trackVisibility` is `true`. | 117 | | **skip** | boolean | false | false | Skip creating the IntersectionObserver. You can use this to enable and disable the observer as needed. If `skip` is set while `inView`, the current state will still be kept. | 118 | | **triggerOnce** | boolean | false | false | Only trigger the observer once. | 119 | | **initialInView** | boolean | false | false | Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. | 120 | | **fallbackInView** | `boolean` | undefined | false | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | 121 | 122 | ### InView Props 123 | 124 | The **``** component also accepts the following props: 125 | 126 | | Name | Type | Default | Required | Description | 127 | | ------------ | -------------------------------------------------------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 128 | | **as** | `string` | 'div' | false | Render the wrapping element as this element. Defaults to `div`. | 129 | | **children** | `({ref, inView, entry}) => React.ReactNode`, `ReactNode` | | true | Children expects a function that receives an object containing the `inView` boolean and a `ref` that should be assigned to the element root. Alternatively pass a plain child, to have the `` deal with the wrapping element. You will also get the `IntersectionObserverEntry` as `entry, giving you more details. | 130 | | **onChange** | `(inView, entry) => void` | | false | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. | 131 | -------------------------------------------------------------------------------- /src/__tests__/useInView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import React, { useCallback } from "react"; 3 | import { defaultFallbackInView, type IntersectionOptions } from "../index"; 4 | import { 5 | destroyIntersectionMocking, 6 | intersectionMockInstance, 7 | mockAllIsIntersecting, 8 | mockIsIntersecting, 9 | } from "../test-utils"; 10 | import { useInView } from "../useInView"; 11 | 12 | const HookComponent = ({ 13 | options, 14 | unmount, 15 | }: { 16 | options?: IntersectionOptions; 17 | unmount?: boolean; 18 | }) => { 19 | const [ref, inView] = useInView(options); 20 | return ( 21 |
22 | {inView.toString()} 23 |
24 | ); 25 | }; 26 | 27 | const LazyHookComponent = ({ options }: { options?: IntersectionOptions }) => { 28 | const [isLoading, setIsLoading] = React.useState(true); 29 | 30 | React.useEffect(() => { 31 | setIsLoading(false); 32 | }, []); 33 | const [ref, inView] = useInView(options); 34 | if (isLoading) return
Loading
; 35 | return ( 36 |
37 | {inView.toString()} 38 |
39 | ); 40 | }; 41 | 42 | test("should create a hook", () => { 43 | const { getByTestId } = render(); 44 | const wrapper = getByTestId("wrapper"); 45 | const instance = intersectionMockInstance(wrapper); 46 | 47 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 48 | }); 49 | 50 | test("should create a hook with array threshold", () => { 51 | const { getByTestId } = render( 52 | , 53 | ); 54 | const wrapper = getByTestId("wrapper"); 55 | const instance = intersectionMockInstance(wrapper); 56 | 57 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 58 | }); 59 | 60 | test("should create a lazy hook", () => { 61 | const { getByTestId } = render(); 62 | const wrapper = getByTestId("wrapper"); 63 | const instance = intersectionMockInstance(wrapper); 64 | 65 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 66 | }); 67 | 68 | test("should create a hook inView", () => { 69 | const { getByText } = render(); 70 | mockAllIsIntersecting(true); 71 | 72 | getByText("true"); 73 | }); 74 | 75 | test("should mock thresholds", () => { 76 | render(); 77 | mockAllIsIntersecting(0.2); 78 | screen.getByText("false"); 79 | mockAllIsIntersecting(0.5); 80 | screen.getByText("true"); 81 | mockAllIsIntersecting(1); 82 | screen.getByText("true"); 83 | }); 84 | 85 | test("should create a hook with initialInView", () => { 86 | const { getByText } = render( 87 | , 88 | ); 89 | getByText("true"); 90 | mockAllIsIntersecting(false); 91 | getByText("false"); 92 | }); 93 | 94 | test("should trigger a hook leaving view", () => { 95 | const { getByText } = render(); 96 | mockAllIsIntersecting(true); 97 | mockAllIsIntersecting(false); 98 | getByText("false"); 99 | }); 100 | 101 | test("should respect trigger once", () => { 102 | const { getByText } = render( 103 | , 104 | ); 105 | mockAllIsIntersecting(true); 106 | mockAllIsIntersecting(false); 107 | 108 | getByText("true"); 109 | }); 110 | 111 | test("should trigger onChange", () => { 112 | const onChange = vi.fn(); 113 | render(); 114 | 115 | mockAllIsIntersecting(false); 116 | expect(onChange).not.toHaveBeenCalled(); 117 | 118 | mockAllIsIntersecting(true); 119 | expect(onChange).toHaveBeenLastCalledWith( 120 | true, 121 | expect.objectContaining({ intersectionRatio: 1, isIntersecting: true }), 122 | ); 123 | 124 | mockAllIsIntersecting(false); 125 | expect(onChange).toHaveBeenLastCalledWith( 126 | false, 127 | expect.objectContaining({ intersectionRatio: 0, isIntersecting: false }), 128 | ); 129 | }); 130 | 131 | test("should respect skip", () => { 132 | const { getByText, rerender } = render( 133 | , 134 | ); 135 | mockAllIsIntersecting(false); 136 | getByText("false"); 137 | 138 | rerender(); 139 | mockAllIsIntersecting(true); 140 | getByText("true"); 141 | }); 142 | 143 | test("should not reset current state if changing skip", () => { 144 | const { getByText, rerender } = render( 145 | , 146 | ); 147 | mockAllIsIntersecting(true); 148 | rerender(); 149 | getByText("true"); 150 | }); 151 | 152 | test("should unmount the hook", () => { 153 | const { unmount, getByTestId } = render(); 154 | const wrapper = getByTestId("wrapper"); 155 | const instance = intersectionMockInstance(wrapper); 156 | unmount(); 157 | expect(instance.unobserve).toHaveBeenCalledWith(wrapper); 158 | }); 159 | 160 | test("inView should be false when component is unmounted", () => { 161 | const { rerender, getByText } = render(); 162 | mockAllIsIntersecting(true); 163 | 164 | getByText("true"); 165 | rerender(); 166 | getByText("false"); 167 | }); 168 | 169 | test("should handle trackVisibility", () => { 170 | render(); 171 | mockAllIsIntersecting(true); 172 | }); 173 | 174 | test("should handle trackVisibility when unsupported", () => { 175 | render(); 176 | }); 177 | 178 | const SwitchHookComponent = ({ 179 | options, 180 | toggle, 181 | unmount, 182 | }: { 183 | options?: IntersectionOptions; 184 | toggle?: boolean; 185 | unmount?: boolean; 186 | }) => { 187 | const [ref, inView] = useInView(options); 188 | return ( 189 | <> 190 |
195 |
200 | 201 | ); 202 | }; 203 | 204 | /** 205 | * This is a test for the case where people move the ref around (please don't) 206 | */ 207 | test("should handle ref removed", () => { 208 | const { rerender, getByTestId } = render(); 209 | mockAllIsIntersecting(true); 210 | 211 | const item1 = getByTestId("item-1"); 212 | const item2 = getByTestId("item-2"); 213 | 214 | // Item1 should be inView 215 | expect(item1.getAttribute("data-inview")).toBe("true"); 216 | expect(item2.getAttribute("data-inview")).toBe("false"); 217 | 218 | rerender(); 219 | mockAllIsIntersecting(true); 220 | 221 | // Item2 should be inView 222 | expect(item1.getAttribute("data-inview")).toBe("false"); 223 | expect(item2.getAttribute("data-inview")).toBe("true"); 224 | 225 | rerender(); 226 | 227 | // Nothing should be inView 228 | expect(item1.getAttribute("data-inview")).toBe("false"); 229 | expect(item2.getAttribute("data-inview")).toBe("false"); 230 | 231 | // Add the ref back 232 | rerender(); 233 | mockAllIsIntersecting(true); 234 | expect(item1.getAttribute("data-inview")).toBe("true"); 235 | expect(item2.getAttribute("data-inview")).toBe("false"); 236 | }); 237 | 238 | const MergeRefsComponent = ({ options }: { options?: IntersectionOptions }) => { 239 | const [inViewRef, inView] = useInView(options); 240 | const setRef = useCallback( 241 | (node: Element | null) => { 242 | inViewRef(node); 243 | }, 244 | [inViewRef], 245 | ); 246 | 247 | return
; 248 | }; 249 | 250 | test("should handle ref merged", () => { 251 | const { rerender, getByTestId } = render(); 252 | mockAllIsIntersecting(true); 253 | rerender(); 254 | 255 | expect(getByTestId("inview").getAttribute("data-inview")).toBe("true"); 256 | }); 257 | 258 | const MultipleHookComponent = ({ 259 | options, 260 | }: { 261 | options?: IntersectionOptions; 262 | }) => { 263 | const [ref1, inView1] = useInView(options); 264 | const [ref2, inView2] = useInView(options); 265 | const [ref3, inView3] = useInView(); 266 | 267 | const mergedRefs = useCallback( 268 | (node: Element | null) => { 269 | ref1(node); 270 | ref2(node); 271 | ref3(node); 272 | }, 273 | [ref1, ref2, ref3], 274 | ); 275 | 276 | return ( 277 |
278 |
279 | {inView1} 280 |
281 |
282 | {inView2} 283 |
284 |
285 | {inView3} 286 |
287 |
288 | ); 289 | }; 290 | 291 | test("should handle multiple hooks on the same element", () => { 292 | const { getByTestId } = render( 293 | , 294 | ); 295 | mockAllIsIntersecting(true); 296 | expect(getByTestId("item-1").getAttribute("data-inview")).toBe("true"); 297 | expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); 298 | expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true"); 299 | }); 300 | 301 | test("should handle thresholds missing on observer instance", () => { 302 | render(); 303 | const wrapper = screen.getByTestId("wrapper"); 304 | const instance = intersectionMockInstance(wrapper); 305 | // @ts-expect-error 306 | instance.thresholds = undefined; 307 | mockAllIsIntersecting(true); 308 | 309 | screen.getByText("true"); 310 | }); 311 | 312 | test("should handle thresholds missing on observer instance with no threshold set", () => { 313 | render(); 314 | const wrapper = screen.getByTestId("wrapper"); 315 | const instance = intersectionMockInstance(wrapper); 316 | // @ts-expect-error 317 | instance.thresholds = undefined; 318 | mockAllIsIntersecting(true); 319 | 320 | screen.getByText("true"); 321 | }); 322 | 323 | const HookComponentWithEntry = ({ 324 | options, 325 | unmount, 326 | }: { 327 | options?: IntersectionOptions; 328 | unmount?: boolean; 329 | }) => { 330 | const { ref, entry } = useInView(options); 331 | return ( 332 |
333 | {entry && Object.entries(entry).map(([key, value]) => `${key}: ${value}`)} 334 |
335 | ); 336 | }; 337 | 338 | test("should set intersection ratio as the largest threshold smaller than trigger", () => { 339 | render( 340 | , 341 | ); 342 | const wrapper = screen.getByTestId("wrapper"); 343 | 344 | mockIsIntersecting(wrapper, 0.5); 345 | screen.getByText(/intersectionRatio: 0.5/); 346 | }); 347 | 348 | test("should handle fallback if unsupported", () => { 349 | destroyIntersectionMocking(); 350 | // @ts-expect-error 351 | window.IntersectionObserver = undefined; 352 | const { rerender } = render( 353 | , 354 | ); 355 | screen.getByText("true"); 356 | 357 | rerender(); 358 | screen.getByText("false"); 359 | 360 | expect(() => { 361 | vi.spyOn(console, "error").mockImplementation(() => {}); 362 | rerender(); 363 | // @ts-expect-error 364 | console.error.mockRestore(); 365 | }).toThrowErrorMatchingInlineSnapshot( 366 | `[TypeError: IntersectionObserver is not a constructor]`, 367 | ); 368 | }); 369 | 370 | test("should handle defaultFallbackInView if unsupported", () => { 371 | destroyIntersectionMocking(); 372 | // @ts-expect-error 373 | window.IntersectionObserver = undefined; 374 | defaultFallbackInView(true); 375 | const { rerender } = render(); 376 | screen.getByText("true"); 377 | 378 | defaultFallbackInView(false); 379 | rerender(); 380 | screen.getByText("false"); 381 | 382 | defaultFallbackInView(undefined); 383 | expect(() => { 384 | vi.spyOn(console, "error").mockImplementation(() => {}); 385 | rerender(); 386 | // @ts-expect-error 387 | console.error.mockRestore(); 388 | }).toThrowErrorMatchingInlineSnapshot( 389 | `[TypeError: IntersectionObserver is not a constructor]`, 390 | ); 391 | }); 392 | 393 | test("should restore the browser IntersectionObserver", () => { 394 | expect(vi.isMockFunction(window.IntersectionObserver)).toBe(true); 395 | destroyIntersectionMocking(); 396 | 397 | // This should restore the original IntersectionObserver 398 | expect(window.IntersectionObserver).toBeDefined(); 399 | expect(vi.isMockFunction(window.IntersectionObserver)).toBe(false); 400 | }); 401 | -------------------------------------------------------------------------------- /src/__tests__/useOnInView.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from "@testing-library/react"; 2 | import { useCallback, useEffect, useState } from "react"; 3 | import type { IntersectionEffectOptions } from ".."; 4 | import { intersectionMockInstance, mockAllIsIntersecting } from "../test-utils"; 5 | import { useOnInView } from "../useOnInView"; 6 | 7 | const OnInViewChangedComponent = ({ 8 | options, 9 | unmount, 10 | }: { 11 | options?: IntersectionEffectOptions; 12 | unmount?: boolean; 13 | }) => { 14 | const [inView, setInView] = useState(false); 15 | const [callCount, setCallCount] = useState(0); 16 | const [cleanupCount, setCleanupCount] = useState(0); 17 | 18 | const inViewRef = useOnInView((isInView) => { 19 | setInView(isInView); 20 | setCallCount((prev) => prev + 1); 21 | if (!isInView) { 22 | setCleanupCount((prev) => prev + 1); 23 | } 24 | }, options); 25 | 26 | return ( 27 |
34 | {inView.toString()} 35 |
36 | ); 37 | }; 38 | 39 | const LazyOnInViewChangedComponent = ({ 40 | options, 41 | }: { 42 | options?: IntersectionEffectOptions; 43 | }) => { 44 | const [isLoading, setIsLoading] = useState(true); 45 | const [inView, setInView] = useState(false); 46 | 47 | useEffect(() => { 48 | setIsLoading(false); 49 | }, []); 50 | 51 | const inViewRef = useOnInView((isInView) => { 52 | setInView(isInView); 53 | }, options); 54 | 55 | if (isLoading) return
Loading
; 56 | 57 | return ( 58 |
59 | {inView.toString()} 60 |
61 | ); 62 | }; 63 | 64 | const OnInViewChangedComponentWithoutCleanup = ({ 65 | options, 66 | unmount, 67 | }: { 68 | options?: IntersectionEffectOptions; 69 | unmount?: boolean; 70 | }) => { 71 | const [callCount, setCallCount] = useState(0); 72 | const inViewRef = useOnInView(() => { 73 | setCallCount((prev) => prev + 1); 74 | }, options); 75 | 76 | return ( 77 |
82 | ); 83 | }; 84 | 85 | const ThresholdTriggerComponent = ({ 86 | options, 87 | }: { 88 | options?: IntersectionEffectOptions; 89 | }) => { 90 | const [triggerCount, setTriggerCount] = useState(0); 91 | const [cleanupCount, setCleanupCount] = useState(0); 92 | const [lastRatio, setLastRatio] = useState(null); 93 | const [triggeredThresholds, setTriggeredThresholds] = useState([]); 94 | 95 | const inViewRef = useOnInView((isInView, entry) => { 96 | setTriggerCount((prev) => prev + 1); 97 | setLastRatio(entry.intersectionRatio); 98 | 99 | if (isInView) { 100 | // Add this ratio to our list of triggered thresholds 101 | setTriggeredThresholds((prev) => [...prev, entry.intersectionRatio]); 102 | } else { 103 | setCleanupCount((prev) => prev + 1); 104 | } 105 | }, options); 106 | 107 | return ( 108 |
116 | Tracking thresholds 117 |
118 | ); 119 | }; 120 | 121 | test("should create a hook with useOnInView", () => { 122 | const { getByTestId } = render(); 123 | const wrapper = getByTestId("wrapper"); 124 | const instance = intersectionMockInstance(wrapper); 125 | 126 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 127 | }); 128 | 129 | test("should create a hook with array threshold", () => { 130 | const { getByTestId } = render( 131 | , 132 | ); 133 | const wrapper = getByTestId("wrapper"); 134 | const instance = intersectionMockInstance(wrapper); 135 | 136 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 137 | }); 138 | 139 | test("should create a lazy hook with useOnInView", () => { 140 | const { getByTestId } = render(); 141 | const wrapper = getByTestId("wrapper"); 142 | const instance = intersectionMockInstance(wrapper); 143 | 144 | expect(instance.observe).toHaveBeenCalledWith(wrapper); 145 | }); 146 | 147 | test("should call the callback when element comes into view", () => { 148 | const { getByTestId } = render(); 149 | mockAllIsIntersecting(true); 150 | 151 | const wrapper = getByTestId("wrapper"); 152 | expect(wrapper.getAttribute("data-inview")).toBe("true"); 153 | expect(wrapper.getAttribute("data-call-count")).toBe("1"); 154 | }); 155 | 156 | test("should ignore initial false intersection", () => { 157 | const { getByTestId } = render(); 158 | const wrapper = getByTestId("wrapper"); 159 | 160 | mockAllIsIntersecting(false); 161 | expect(wrapper.getAttribute("data-call-count")).toBe("0"); 162 | 163 | mockAllIsIntersecting(true); 164 | expect(wrapper.getAttribute("data-call-count")).toBe("1"); 165 | }); 166 | 167 | test("should call cleanup when element leaves view", () => { 168 | const { getByTestId } = render(); 169 | mockAllIsIntersecting(true); 170 | mockAllIsIntersecting(false); 171 | 172 | const wrapper = getByTestId("wrapper"); 173 | expect(wrapper.getAttribute("data-inview")).toBe("false"); 174 | expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); 175 | }); 176 | 177 | test("should respect threshold values", () => { 178 | const { getByTestId } = render( 179 | , 180 | ); 181 | const wrapper = getByTestId("wrapper"); 182 | 183 | mockAllIsIntersecting(0.2); 184 | expect(wrapper.getAttribute("data-inview")).toBe("false"); 185 | 186 | mockAllIsIntersecting(0.5); 187 | expect(wrapper.getAttribute("data-inview")).toBe("true"); 188 | 189 | mockAllIsIntersecting(1); 190 | expect(wrapper.getAttribute("data-inview")).toBe("true"); 191 | }); 192 | 193 | test("should respect triggerOnce option", () => { 194 | const { getByTestId } = render( 195 | <> 196 | 197 | 198 | , 199 | ); 200 | const wrapper = getByTestId("wrapper"); 201 | const wrapperTriggerOnce = getByTestId("wrapper-no-cleanup"); 202 | 203 | mockAllIsIntersecting(true); 204 | expect(wrapper.getAttribute("data-call-count")).toBe("1"); 205 | expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); 206 | mockAllIsIntersecting(false); 207 | expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); 208 | mockAllIsIntersecting(true); 209 | expect(wrapper.getAttribute("data-call-count")).toBe("3"); 210 | expect(wrapperTriggerOnce.getAttribute("data-call-count")).toBe("1"); 211 | }); 212 | 213 | test("should respect skip option", () => { 214 | const { getByTestId, rerender } = render( 215 | , 216 | ); 217 | mockAllIsIntersecting(true); 218 | 219 | const wrapper = getByTestId("wrapper"); 220 | expect(wrapper.getAttribute("data-inview")).toBe("false"); 221 | expect(wrapper.getAttribute("data-call-count")).toBe("0"); 222 | 223 | rerender(); 224 | mockAllIsIntersecting(true); 225 | 226 | expect(wrapper.getAttribute("data-inview")).toBe("true"); 227 | expect(wrapper.getAttribute("data-call-count")).toBe("1"); 228 | }); 229 | 230 | test("should handle unmounting properly", () => { 231 | const { unmount, getByTestId } = render(); 232 | const wrapper = getByTestId("wrapper"); 233 | const instance = intersectionMockInstance(wrapper); 234 | 235 | unmount(); 236 | expect(instance.unobserve).toHaveBeenCalledWith(wrapper); 237 | }); 238 | 239 | test("should handle ref changes", () => { 240 | const { rerender, getByTestId } = render(); 241 | mockAllIsIntersecting(true); 242 | mockAllIsIntersecting(false); 243 | 244 | rerender(); 245 | 246 | // Component should register the element leaving view before ref removal 247 | const wrapper = getByTestId("wrapper"); 248 | expect(wrapper.getAttribute("data-cleanup-count")).toBe("1"); 249 | 250 | // Add the ref back 251 | rerender(); 252 | mockAllIsIntersecting(true); 253 | 254 | expect(wrapper.getAttribute("data-inview")).toBe("true"); 255 | }); 256 | 257 | // Test for merging refs 258 | const MergeRefsComponent = ({ 259 | options, 260 | }: { 261 | options?: IntersectionEffectOptions; 262 | }) => { 263 | const [inView, setInView] = useState(false); 264 | 265 | const inViewRef = useOnInView((isInView) => { 266 | setInView(isInView); 267 | }, options); 268 | 269 | const setRef = useCallback( 270 | (node: Element | null) => inViewRef(node), 271 | [inViewRef], 272 | ); 273 | 274 | return ( 275 |
276 | ); 277 | }; 278 | 279 | test("should handle merged refs", () => { 280 | const { rerender, getByTestId } = render(); 281 | mockAllIsIntersecting(true); 282 | rerender(); 283 | 284 | expect(getByTestId("inview").getAttribute("data-inview")).toBe("true"); 285 | }); 286 | 287 | // Test multiple callbacks on the same element 288 | const MultipleCallbacksComponent = ({ 289 | options, 290 | }: { 291 | options?: IntersectionEffectOptions; 292 | }) => { 293 | const [inView1, setInView1] = useState(false); 294 | const [inView2, setInView2] = useState(false); 295 | const [inView3, setInView3] = useState(false); 296 | 297 | const ref1 = useOnInView((isInView) => { 298 | setInView1(isInView); 299 | }, options); 300 | 301 | const ref2 = useOnInView((isInView) => { 302 | setInView2(isInView); 303 | }, options); 304 | 305 | const ref3 = useOnInView((isInView) => { 306 | setInView3(isInView); 307 | }); 308 | 309 | const mergedRefs = useCallback( 310 | (node: Element | null) => { 311 | const cleanup = [ref1(node), ref2(node), ref3(node)]; 312 | return () => 313 | cleanup.forEach((fn) => { 314 | fn?.(); 315 | }); 316 | }, 317 | [ref1, ref2, ref3], 318 | ); 319 | 320 | return ( 321 |
322 |
323 | {inView1.toString()} 324 |
325 |
326 | {inView2.toString()} 327 |
328 |
329 | {inView3.toString()} 330 |
331 |
332 | ); 333 | }; 334 | 335 | test("should handle multiple callbacks on the same element", () => { 336 | const { getByTestId } = render( 337 | , 338 | ); 339 | mockAllIsIntersecting(true); 340 | 341 | expect(getByTestId("item-1").getAttribute("data-inview")).toBe("true"); 342 | expect(getByTestId("item-2").getAttribute("data-inview")).toBe("true"); 343 | expect(getByTestId("item-3").getAttribute("data-inview")).toBe("true"); 344 | }); 345 | 346 | test("should pass the element to the callback", () => { 347 | let capturedElement: Element | undefined; 348 | 349 | const ElementTestComponent = () => { 350 | const inViewRef = useOnInView((_, entry) => { 351 | capturedElement = entry.target; 352 | }); 353 | 354 | return
; 355 | }; 356 | 357 | const { getByTestId } = render(); 358 | const element = getByTestId("element-test"); 359 | mockAllIsIntersecting(true); 360 | 361 | expect(capturedElement).toBe(element); 362 | }); 363 | 364 | test("should track which threshold triggered the visibility change", () => { 365 | // Using multiple specific thresholds 366 | const { getByTestId } = render( 367 | , 368 | ); 369 | const element = getByTestId("threshold-trigger"); 370 | 371 | // Initially not in view 372 | expect(element.getAttribute("data-trigger-count")).toBe("0"); 373 | 374 | // Trigger at exactly the first threshold (0.25) 375 | mockAllIsIntersecting(0.25); 376 | expect(element.getAttribute("data-trigger-count")).toBe("1"); 377 | expect(element.getAttribute("data-last-ratio")).toBe("0.25"); 378 | 379 | // Go out of view 380 | mockAllIsIntersecting(0); 381 | expect(element.getAttribute("data-trigger-count")).toBe("2"); 382 | 383 | // Trigger at exactly the second threshold (0.5) 384 | mockAllIsIntersecting(0.5); 385 | expect(element.getAttribute("data-trigger-count")).toBe("3"); 386 | expect(element.getAttribute("data-last-ratio")).toBe("0.50"); 387 | 388 | // Go out of view 389 | mockAllIsIntersecting(0); 390 | expect(element.getAttribute("data-trigger-count")).toBe("4"); 391 | 392 | // Trigger at exactly the third threshold (0.75) 393 | mockAllIsIntersecting(0.75); 394 | expect(element.getAttribute("data-trigger-count")).toBe("5"); 395 | expect(element.getAttribute("data-last-ratio")).toBe("0.75"); 396 | 397 | // Check all triggered thresholds were recorded 398 | const triggeredThresholds = JSON.parse( 399 | element.getAttribute("data-triggered-thresholds") || "[]", 400 | ); 401 | expect(triggeredThresholds).toContain(0.25); 402 | expect(triggeredThresholds).toContain(0.5); 403 | expect(triggeredThresholds).toContain(0.75); 404 | }); 405 | 406 | test("should track thresholds when crossing multiple in a single update", () => { 407 | // Using multiple specific thresholds 408 | const { getByTestId } = render( 409 | , 410 | ); 411 | const element = getByTestId("threshold-trigger"); 412 | 413 | // Initially not in view 414 | expect(element.getAttribute("data-trigger-count")).toBe("0"); 415 | 416 | // Jump straight to 0.7 (crosses 0.2, 0.4, 0.6 thresholds) 417 | // The IntersectionObserver will still only call the callback once 418 | // with the highest threshold that was crossed 419 | mockAllIsIntersecting(0.7); 420 | expect(element.getAttribute("data-trigger-count")).toBe("1"); 421 | expect(element.getAttribute("data-cleanup-count")).toBe("0"); 422 | expect(element.getAttribute("data-last-ratio")).toBe("0.60"); 423 | 424 | // Go out of view 425 | mockAllIsIntersecting(0); 426 | expect(element.getAttribute("data-cleanup-count")).toBe("1"); 427 | expect(element.getAttribute("data-trigger-count")).toBe("2"); 428 | 429 | // Change to 0.5 (crosses 0.2, 0.4 thresholds) 430 | mockAllIsIntersecting(0.5); 431 | expect(element.getAttribute("data-trigger-count")).toBe("3"); 432 | expect(element.getAttribute("data-last-ratio")).toBe("0.40"); 433 | 434 | // Jump to full visibility - should cleanup the 0.5 callback 435 | mockAllIsIntersecting(1.0); 436 | expect(element.getAttribute("data-trigger-count")).toBe("4"); 437 | expect(element.getAttribute("data-cleanup-count")).toBe("1"); 438 | expect(element.getAttribute("data-last-ratio")).toBe("0.80"); 439 | }); 440 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-intersection-observer 2 | 3 | [![Version Badge][npm-version-svg]][package-url] 4 | [![Test][test-image]][test-url] 5 | [![License][license-image]][license-url] 6 | [![Downloads][downloads-image]][downloads-url] 7 | ![npm package minimized gzipped size](https://img.shields.io/bundlejs/size/react-intersection-observer?exports=InView%2C%20useOnInView%2C%20useInView&externals=react&format=both) 8 | 9 | A React implementation of the [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) 10 | to tell you when an element enters or leaves the viewport. Contains [Hooks](#useinview-hook), [render props](#render-props), and [plain children](#plain-children) implementations. 11 | 12 | ## Features 13 | 14 | - 🪝 **Hooks or Component API** - With `useInView` and `useOnInView` it's easier 15 | than ever to monitor elements 16 | - ⚡️ **Optimized performance** - Reuses Intersection Observer instances where 17 | possible 18 | - ⚙️ **Matches native API** - Intuitive to use 19 | - 🛠 **Written in TypeScript** - It'll fit right into your existing TypeScript 20 | project 21 | - 🧪 **Ready to test** - Mocks the Intersection Observer for easy testing with 22 | [Jest](https://jestjs.io/) or [Vitest](https://vitest.dev/) 23 | - 🌳 **Tree-shakeable** - Only include the parts you use 24 | - 💥 **Tiny bundle** - Around **~1.15kB** for `useInView` and **~1.6kB** for 25 | `` ![useInView](https://img.shields.io/bundlejs/size/react-intersection-observer?exports=useInView&externals=react&format=both&label=useInView) ![InView](https://img.shields.io/bundlejs/size/react-intersection-observer?exports=InView&externals=react&format=both&label=InView) 26 | ![useOnInView](https://img.shields.io/bundlejs/size/react-intersection-observer?exports=useOnInView&externals=react&format=both&label=useOnInView) 27 | 28 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/thebuilder/react-intersection-observer) 29 | 30 | ## Installation 31 | 32 | Install the package with your package manager of choice: 33 | 34 | ```sh 35 | npm install react-intersection-observer --save 36 | ``` 37 | 38 | ## Usage 39 | 40 | ### `useInView` hook 41 | 42 | ```js 43 | // Use object destructuring, so you don't need to remember the exact order 44 | const { ref, inView, entry } = useInView(options); 45 | 46 | // Or array destructuring, making it easy to customize the field names 47 | const [ref, inView, entry] = useInView(options); 48 | ``` 49 | 50 | The `useInView` hook makes it easy to monitor the `inView` state of your 51 | components. Call the `useInView` hook with the (optional) [options](#options) 52 | you need. It will return an array containing a `ref`, the `inView` status and 53 | the current 54 | [`entry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry). 55 | Assign the `ref` to the DOM element you want to monitor, and the hook will 56 | report the status. 57 | 58 | ```jsx 59 | import React from "react"; 60 | import { useInView } from "react-intersection-observer"; 61 | 62 | const Component = () => { 63 | const { ref, inView, entry } = useInView({ 64 | /* Optional options */ 65 | threshold: 0, 66 | }); 67 | 68 | return ( 69 |
70 |

{`Header inside viewport ${inView}.`}

71 |
72 | ); 73 | }; 74 | ``` 75 | 76 | > **Note:** The first `false` notification from the underlying IntersectionObserver is ignored so your handlers only run after a real visibility change. Subsequent transitions still report both `true` and `false` states as the element enters and leaves the viewport. 77 | 78 | ### `useOnInView` hook 79 | 80 | ```js 81 | const inViewRef = useOnInView( 82 | (inView, entry) => { 83 | if (inView) { 84 | // Do something with the element that came into view 85 | console.log("Element is in view", entry.target); 86 | } else { 87 | console.log("Element left view", entry.target); 88 | } 89 | }, 90 | options // Optional IntersectionObserver options 91 | ); 92 | ``` 93 | 94 | The `useOnInView` hook provides a more direct alternative to `useInView`. It 95 | takes a callback function and returns a ref that you can assign to the DOM 96 | element you want to monitor. Whenever the element enters or leaves the viewport, 97 | your callback will be triggered with the latest in-view state. 98 | 99 | Key differences from `useInView`: 100 | - **No re-renders** - This hook doesn't update any state, making it ideal for 101 | performance-critical scenarios 102 | - **Direct element access** - Your callback receives the actual 103 | IntersectionObserverEntry with the `target` element 104 | - **Boolean-first callback** - The callback receives the current `inView` 105 | boolean as the first argument, matching the `onChange` signature from 106 | `useInView` 107 | - **Similar options** - Accepts all the same [options](#options) as `useInView` 108 | except `onChange`, `initialInView`, and `fallbackInView` 109 | 110 | > **Note:** Just like `useInView`, the initial `false` notification is skipped. Your callback fires the first time the element becomes visible (and on every subsequent enter/leave transition). 111 | 112 | ```jsx 113 | import React from "react"; 114 | import { useOnInView } from "react-intersection-observer"; 115 | 116 | const Component = () => { 117 | // Track when element appears without causing re-renders 118 | const trackingRef = useOnInView( 119 | (inView, entry) => { 120 | if (inView) { 121 | // Element is in view - perhaps log an impression 122 | console.log("Element appeared in view", entry.target); 123 | } else { 124 | console.log("Element left view", entry.target); 125 | } 126 | }, 127 | { 128 | /* Optional options */ 129 | threshold: 0.5, 130 | triggerOnce: true, 131 | }, 132 | ); 133 | 134 | return ( 135 |
136 |

This element is being tracked without re-renders

137 |
138 | ); 139 | }; 140 | ``` 141 | 142 | ### Render props 143 | 144 | To use the `` component, you pass it a function. It will be called 145 | whenever the state changes, with the new value of `inView`. In addition to the 146 | `inView` prop, children also receive a `ref` that should be set on the 147 | containing DOM element. This is the element that the Intersection Observer will 148 | monitor. 149 | 150 | If you need it, you can also access the 151 | [`IntersectionObserverEntry`](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserverEntry) 152 | on `entry`, giving you access to all the details about the current intersection 153 | state. 154 | 155 | ```jsx 156 | import { InView } from "react-intersection-observer"; 157 | 158 | const Component = () => ( 159 | 160 | {({ inView, ref, entry }) => ( 161 |
162 |

{`Header inside viewport ${inView}.`}

163 |
164 | )} 165 |
166 | ); 167 | 168 | export default Component; 169 | ``` 170 | 171 | > **Note:** `` mirrors the hook behaviour—it suppresses the very first `false` notification so render props and `onChange` handlers only run after a genuine visibility change. 172 | 173 | ### Plain children 174 | 175 | You can pass any element to the ``, and it will handle creating the 176 | wrapping DOM element. Add a handler to the `onChange` method, and control the 177 | state in your own component. Any extra props you add to `` will be 178 | passed to the HTML element, allowing you set the `className`, `style`, etc. 179 | 180 | ```jsx 181 | import { InView } from "react-intersection-observer"; 182 | 183 | const Component = () => ( 184 | console.log("Inview:", inView)}> 185 |

Plain children are always rendered. Use onChange to monitor state.

186 |
187 | ); 188 | 189 | export default Component; 190 | ``` 191 | 192 | > [!NOTE] 193 | > When rendering a plain child, make sure you keep your HTML output 194 | > semantic. Change the `as` to match the context, and add a `className` to style 195 | > the ``. The component does not support Ref Forwarding, so if you 196 | > need a `ref` to the HTML element, use the Render Props version instead. 197 | 198 | ## API 199 | 200 | ### Options 201 | 202 | Provide these as the options argument in the `useInView` hook or as props on the 203 | **``** component. 204 | 205 | | Name | Type | Default | Description | 206 | | ---------------------- | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 207 | | **root** | `Element` | `document` | The Intersection Observer interface's read-only root property identifies the Element or Document whose bounds are treated as the bounding box of the viewport for the element which is the observer's target. If the root is `null`, then the bounds of the actual document viewport are used. | 208 | | **rootMargin** | `string` | `'0px'` | Margin around the root. Can have values similar to the CSS margin property, e.g. `"10px 20px 30px 40px"` (top, right, bottom, left). Also supports percentages, to check if an element intersects with the center of the viewport for example `"-50% 0% -50% 0%"`. | 209 | | **threshold** | `number` or `number[]` | `0` | Number between `0` and `1` indicating the percentage that should be visible before triggering. Can also be an array of numbers, to create multiple trigger points. | 210 | | **onChange** | `(inView, entry) => void` | `undefined` | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. | 211 | | **trackVisibility** 🧪 | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. | 212 | | **delay** 🧪 | `number` | `undefined` | A number indicating the minimum delay in milliseconds between notifications from this observer for a given target. This must be set to at least `100` if `trackVisibility` is `true`. | 213 | | **skip** | `boolean` | `false` | Skip creating the IntersectionObserver. You can use this to enable and disable the observer as needed. If `skip` is set while `inView`, the current state will still be kept. | 214 | | **triggerOnce** | `boolean` | `false` | Only trigger the observer once. | 215 | | **initialInView** | `boolean` | `false` | Set the initial value of the `inView` boolean. This can be used if you expect the element to be in the viewport to start with, and you want to trigger something when it leaves. | 216 | | **fallbackInView** | `boolean` | `undefined` | If the `IntersectionObserver` API isn't available in the client, the default behavior is to throw an Error. You can set a specific fallback behavior, and the `inView` value will be set to this instead of failing. To set a global default, you can set it with the `defaultFallbackInView()` | 217 | 218 | `useOnInView` accepts the same options as `useInView` except `onChange`, 219 | `initialInView`, and `fallbackInView`. 220 | 221 | ### InView Props 222 | 223 | The **``** component also accepts the following props: 224 | 225 | | Name | Type | Default | Description | 226 | | ------------ | ---------------------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 227 | | **as** | `IntrinsicElement` | `'div'` | Render the wrapping element as this element. Defaults to `div`. If you want to use a custom component, please use the `useInView` hook or a render prop instead to manage the reference explictly. | 228 | | **children** | `({ref, inView, entry}) => ReactNode` or `ReactNode` | `undefined` | Children expects a function that receives an object containing the `inView` boolean and a `ref` that should be assigned to the element root. Alternatively pass a plain child, to have the `` deal with the wrapping element. You will also get the `IntersectionObserverEntry` as `entry`, giving you more details. | 229 | 230 | ### Intersection Observer v2 🧪 231 | 232 | The new 233 | [v2 implementation of IntersectionObserver](https://developers.google.com/web/updates/2019/02/intersectionobserver-v2) 234 | extends the original API, so you can track if the element is covered by another 235 | element or has filters applied to it. Useful for blocking clickjacking attempts 236 | or tracking ad exposure. 237 | 238 | To use it, you'll need to add the new `trackVisibility` and `delay` options. 239 | When you get the `entry` back, you can then monitor if `isVisible` is `true`. 240 | 241 | ```jsx 242 | const TrackVisible = () => { 243 | const { ref, entry } = useInView({ trackVisibility: true, delay: 100 }); 244 | return
{entry?.isVisible}
; 245 | }; 246 | ``` 247 | 248 | This is still a very new addition, so check 249 | [caniuse](https://caniuse.com/#feat=intersectionobserver-v2) for current browser 250 | support. If `trackVisibility` has been set, and the current browser doesn't 251 | support it, a fallback has been added to always report `isVisible` as `true`. 252 | 253 | It's not added to the TypeScript `lib.d.ts` file yet, so you will also have to 254 | extend the `IntersectionObserverEntry` with the `isVisible` boolean. 255 | 256 | ## Recipes 257 | 258 | The `IntersectionObserver` itself is just a simple but powerful tool. Here's a 259 | few ideas for how you can use it. 260 | 261 | - [Lazy image load](docs/Recipes.md#lazy-image-load) 262 | - [Trigger animations](docs/Recipes.md#trigger-animations) 263 | - [Track impressions](docs/Recipes.md#track-impressions) _(Google Analytics, Tag 264 | Manager, etc.)_ 265 | 266 | ## FAQ 267 | 268 | ### How can I assign multiple refs to a component? 269 | 270 | You can wrap multiple `ref` assignments in a single `useCallback`: 271 | 272 | ```jsx 273 | import React, { useRef, useCallback } from "react"; 274 | import { useInView } from "react-intersection-observer"; 275 | 276 | function Component(props) { 277 | const ref = useRef(); 278 | const { ref: inViewRef, inView } = useInView(); 279 | 280 | // Use `useCallback` so we don't recreate the function on each render 281 | const setRefs = useCallback( 282 | (node) => { 283 | // Ref's from useRef needs to have the node assigned to `current` 284 | ref.current = node; 285 | // Callback refs, like the one from `useInView`, is a function that takes the node as an argument 286 | inViewRef(node); 287 | }, 288 | [inViewRef], 289 | ); 290 | 291 | return
Shared ref is visible: {inView}
; 292 | } 293 | ``` 294 | 295 | ### `rootMargin` isn't working as expected 296 | 297 | When using `rootMargin`, the margin gets added to the current `root` - If your 298 | application is running inside a `