Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 78 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ to tell you when an element enters or leaves the viewport. Contains [Hooks](#use

## Features

- 🪝 **Hooks or Component API** - With `useInView` it's easier than ever to
monitor elements
- 🪝 **Hooks or Component API** - With `useInView` and `useOnInView` it's easier
than ever to monitor elements
- ⚡️ **Optimized performance** - Reuses Intersection Observer instances where
possible
- ⚙️ **Matches native API** - Intuitive to use
Expand Down Expand Up @@ -71,6 +71,72 @@ const Component = () => {
};
```

> **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.

### `useOnInView` hook

```js
const inViewRef = useOnInView(
(inView, entry) => {
if (inView) {
// Do something with the element that came into view
console.log("Element is in view", entry.target);
} else {
console.log("Element left view", entry.target);
}
},
options // Optional IntersectionObserver options
);
```

The `useOnInView` hook provides a more direct alternative to `useInView`. It
takes a callback function and returns a ref that you can assign to the DOM
element you want to monitor. Whenever the element enters or leaves the viewport,
your callback will be triggered with the latest in-view state.

Key differences from `useInView`:
- **No re-renders** - This hook doesn't update any state, making it ideal for
performance-critical scenarios
- **Direct element access** - Your callback receives the actual
IntersectionObserverEntry with the `target` element
- **Boolean-first callback** - The callback receives the current `inView`
boolean as the first argument, matching the `onChange` signature from
`useInView`
- **Similar options** - Accepts all the same [options](#options) as `useInView`
except `onChange`, `initialInView`, and `fallbackInView`

> **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).

```jsx
import React from "react";
import { useOnInView } from "react-intersection-observer";

const Component = () => {
// Track when element appears without causing re-renders
const trackingRef = useOnInView(
(inView, entry) => {
if (inView) {
// Element is in view - perhaps log an impression
console.log("Element appeared in view", entry.target);
} else {
console.log("Element left view", entry.target);
}
},
{
/* Optional options */
threshold: 0.5,
triggerOnce: true,
},
);

return (
<div ref={trackingRef}>
<h2>This element is being tracked without re-renders</h2>
</div>
);
};
```

### Render props

To use the `<InView>` component, you pass it a function. It will be called
Expand All @@ -87,18 +153,20 @@ state.
```jsx
import { InView } from "react-intersection-observer";

const Component = () => (
<InView>
{({ inView, ref, entry }) => (
const Component = () => (
<InView>
{({ inView, ref, entry }) => (
<div ref={ref}>
<h2>{`Header inside viewport ${inView}.`}</h2>
</div>
)}
</InView>
);

export default Component;
```
export default Component;
```

> **Note:** `<InView>` mirrors the hook behaviour—it suppresses the very first `false` notification so render props and `onChange` handlers only run after a genuine visibility change.

### Plain children

Expand Down Expand Up @@ -145,6 +213,9 @@ Provide these as the options argument in the `useInView` hook or as props on the
| **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. |
| **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()` |

`useOnInView` accepts the same options as `useInView` except `onChange`,
`initialInView`, and `fallbackInView`.

### InView Props

The **`<InView />`** component also accepts the following props:
Expand Down
19 changes: 9 additions & 10 deletions docs/Recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ export default LazyAnimation;
## Track impressions

You can use `IntersectionObserver` to track when a user views your element, and
fire an event on your tracking service.
fire an event on your tracking service. Consider using the `useOnInView` to
trigger changes via a callback.

- Set `triggerOnce`, to only trigger an event the first time the element enters
the viewport.
Expand All @@ -115,22 +116,20 @@ fire an event on your tracking service.
- Instead of `threshold`, you can use `rootMargin` to have a fixed amount be
visible before triggering. Use a negative margin value, like `-100px 0px`, to
have it go inwards. You can also use a percentage value, instead of pixels.
- You can use the `onChange` callback to trigger the tracking.

```jsx
import * as React from "react";
import { useInView } from "react-intersection-observer";
import { useOnInView } from "react-intersection-observer";

const TrackImpression = () => {
const { ref } = useInView({
triggerOnce: true,
rootMargin: "-100px 0",
onChange: (inView) => {
const ref = useOnInView((inView) => {
if (inView) {
// Fire a tracking event to your tracking service of choice.
dataLayer.push("Section shown"); // Here's a GTM dataLayer push
// Fire a tracking event to your tracking service of choice.
dataLayer.push("Section shown"); // Here's a GTM dataLayer push
}
},
}, {
triggerOnce: true,
rootMargin: "-100px 0",
});

return (
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,19 +94,25 @@
"path": "dist/index.mjs",
"name": "InView",
"import": "{ InView }",
"limit": "1.8 kB"
"limit": "1.5 kB"
},
{
"path": "dist/index.mjs",
"name": "useInView",
"import": "{ useInView }",
"limit": "1.3 kB"
},
{
"path": "dist/index.mjs",
"name": "useOnInView",
"import": "{ useOnInView }",
"limit": "1.1 kB"
},
{
"path": "dist/index.mjs",
"name": "observe",
"import": "{ observe }",
"limit": "1 kB"
"limit": "0.9 kB"
}
],
"peerDependencies": {
Expand Down
14 changes: 14 additions & 0 deletions src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,15 @@ export class InView extends React.Component<
> {
node: Element | null = null;
_unobserveCb: (() => void) | null = null;
lastInView: boolean | undefined;

constructor(props: IntersectionObserverProps | PlainChildrenProps) {
super(props);
this.state = {
inView: !!props.initialInView,
entry: undefined,
};
this.lastInView = props.initialInView;
}

componentDidMount() {
Expand Down Expand Up @@ -112,6 +114,9 @@ export class InView extends React.Component<
fallbackInView,
} = this.props;

if (this.lastInView === undefined) {
this.lastInView = this.props.initialInView;
}
this._unobserveCb = observe(
this.node,
this.handleChange,
Expand Down Expand Up @@ -142,6 +147,7 @@ export class InView extends React.Component<
if (!node && !this.props.triggerOnce && !this.props.skip) {
// Reset the state if we get a new node, and we aren't ignoring updates
this.setState({ inView: !!this.props.initialInView, entry: undefined });
this.lastInView = this.props.initialInView;
}
}

Expand All @@ -150,6 +156,14 @@ export class InView extends React.Component<
};

handleChange = (inView: boolean, entry: IntersectionObserverEntry) => {
const previousInView = this.lastInView;
this.lastInView = inView;

// Ignore the very first `false` notification so consumers only hear about actual state changes.
if (previousInView === undefined && !inView) {
return;
}

if (inView && this.props.triggerOnce) {
// If `triggerOnce` is true, we should stop observing the element.
this.unobserve();
Expand Down
11 changes: 7 additions & 4 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,19 @@ test("Should render <InView /> intersecting", () => {
);

mockAllIsIntersecting(false);
expect(callback).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);
expect(callback).not.toHaveBeenCalled();

mockAllIsIntersecting(true);
expect(callback).toHaveBeenLastCalledWith(
true,
expect.objectContaining({ isIntersecting: true }),
);

mockAllIsIntersecting(false);
expect(callback).toHaveBeenLastCalledWith(
false,
expect.objectContaining({ isIntersecting: false }),
);
});

test("should render plain children", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ test("should trigger onChange", () => {
const onChange = vi.fn();
render(<HookComponent options={{ onChange }} />);

mockAllIsIntersecting(false);
expect(onChange).not.toHaveBeenCalled();

mockAllIsIntersecting(true);
expect(onChange).toHaveBeenLastCalledWith(
true,
Expand Down Expand Up @@ -186,12 +189,12 @@ const SwitchHookComponent = ({
<>
<div
data-testid="item-1"
data-inview={!toggle && inView}
data-inview={(!toggle && inView).toString()}
ref={!toggle && !unmount ? ref : undefined}
/>
<div
data-testid="item-2"
data-inview={!!toggle && inView}
data-inview={(!!toggle && inView).toString()}
ref={toggle && !unmount ? ref : undefined}
/>
</>
Expand Down
Loading
Loading