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
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 |
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 }) =>
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 | [](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 | [](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 | [](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 |
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 | 
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 | ``  
26 | 
27 |
28 | [](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 `