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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ Provide these as the options argument in the `useInView` hook or as props on the
| ---------------------- | ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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. |
| **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%"`. |
| **scrollMargin** | `string` | `'0px'` | Margin around nested scroll containers that clip the target. Can have values similar to the CSS margin property, e.g. `"10px 20px 30px 40px"` (top, right, bottom, left). Unlike `rootMargin`, this grows or shrinks every scroll container's clipping rectangle within the root, including the root itself if it is a scroll container. |
| **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. |
| **onChange** | `(inView, entry) => void` | `undefined` | Call this function whenever the in view state changes. It will receive the `inView` boolean, alongside the current `IntersectionObserverEntry`. |
| **trackVisibility** 🧪 | `boolean` | `false` | A boolean indicating whether this Intersection Observer will track visibility changes on the target. |
Expand Down Expand Up @@ -298,9 +299,14 @@ When using `rootMargin`, the margin gets added to the current `root` - If your
application is running inside a `<iframe>`, or you have defined a custom `root`
this will not be the current viewport.

If the target is clipped by a scrollable container inside the `root`, use
`scrollMargin` to change when intersections are calculated for that nested
scroll container.

You can read more about this on these links:

- [Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#The_intersection_root_and_root_margin)
- [IntersectionObserver scrollMargin](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/scrollMargin)
- [w3c/IntersectionObserver: rootMargin ignored within iframe](https://github.com/w3c/IntersectionObserver/issues/283#issuecomment-507397917)
- [w3c/IntersectionObserver: Cannot track intersection with an iframe's viewport](https://github.com/w3c/IntersectionObserver/issues/372)
- [w3c/Support iframe viewport tracking](https://github.com/w3c/IntersectionObserver/pull/465)
Expand Down
5 changes: 4 additions & 1 deletion src/InView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export class InView extends React.Component<
// If a IntersectionObserver option changed, reinit the observer
if (
prevProps.rootMargin !== this.props.rootMargin ||
prevProps.scrollMargin !== this.props.scrollMargin ||
prevProps.root !== this.props.root ||
prevProps.threshold !== this.props.threshold ||
prevProps.skip !== this.props.skip ||
Expand All @@ -109,6 +110,7 @@ export class InView extends React.Component<
threshold,
root,
rootMargin,
scrollMargin,
trackVisibility,
delay,
fallbackInView,
Expand All @@ -124,7 +126,7 @@ export class InView extends React.Component<
threshold,
root,
rootMargin,
// @ts-expect-error
scrollMargin,
trackVisibility,
delay,
},
Expand Down Expand Up @@ -192,6 +194,7 @@ export class InView extends React.Component<
threshold,
root,
rootMargin,
scrollMargin,
onChange,
skip,
trackVisibility,
Expand Down
10 changes: 10 additions & 0 deletions src/__tests__/InView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ test("Should recreate observer when rootMargin change", () => {
expect(instance.unobserve).toHaveBeenCalled();
});

test("Should recreate observer when scrollMargin change", () => {
const { container, rerender } = render(<InView>Inner</InView>);
mockAllIsIntersecting(true);
const instance = intersectionMockInstance(container.children[0]);
vi.spyOn(instance, "unobserve");

rerender(<InView scrollMargin="10px">Inner</InView>);
expect(instance.unobserve).toHaveBeenCalled();
});

test("Should unobserve when triggerOnce comes into view", () => {
const { container } = render(<InView triggerOnce>Inner</InView>);
mockAllIsIntersecting(false);
Expand Down
4 changes: 2 additions & 2 deletions src/__tests__/observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ test("should convert options to id", () => {
expect(
optionsToId({
threshold: 0,
// @ts-expect-error
trackVisibility: true,
delay: 500,
}),
Expand All @@ -49,7 +48,8 @@ test("should convert options to id", () => {
).toMatchInlineSnapshot(`"threshold_0"`);
expect(
optionsToId({
scrollMargin: "10px 20px",
threshold: [0, 0.5, 1],
}),
).toMatchInlineSnapshot(`"threshold_0,0.5,1"`);
).toMatchInlineSnapshot(`"scrollMargin_10px 20px,threshold_0,0.5,1"`);
});
10 changes: 10 additions & 0 deletions src/__tests__/useInView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ test("should create a hook with array threshold", () => {
expect(instance.observe).toHaveBeenCalledWith(wrapper);
});

test("should create a hook with scrollMargin", () => {
const { getByTestId } = render(
<HookComponent options={{ scrollMargin: "10px" }} />,
);
const wrapper = getByTestId("wrapper");
const instance = intersectionMockInstance(wrapper);

expect(instance).toHaveProperty("scrollMargin", "10px");
});

test("should create a lazy hook", () => {
const { getByTestId } = render(<LazyHookComponent />);
const wrapper = getByTestId("wrapper");
Expand Down
14 changes: 13 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ export { useOnInView } from "./useOnInView";

type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;

export type IntersectionObserverInitWithOptions = IntersectionObserverInit & {
/** Margin around nested scroll containers that clip the target, including the root if it is a scroll container. */
scrollMargin?: string;
/** IntersectionObserver v2 - Track the actual visibility of the element */
trackVisibility?: boolean;
/** IntersectionObserver v2 - Set a minimum delay between notifications */
delay?: number;
};

export type ObserverInstanceCallback = (
inView: boolean,
entry: IntersectionObserverEntry,
Expand All @@ -26,11 +35,14 @@ interface RenderProps {
ref: React.RefObject<any> | ((node?: Element | null) => void);
}

export interface IntersectionOptions extends IntersectionObserverInit {
export interface IntersectionOptions
extends IntersectionObserverInitWithOptions {
/** 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.*/
root?: Element | Document | null;
/** Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left). */
rootMargin?: string;
/** Margin around nested scroll containers that clips the target, including the root if it is a scroll container. */
scrollMargin?: string;
/** 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. */
threshold?: number | number[];
/** Only trigger the inView callback once */
Expand Down
18 changes: 11 additions & 7 deletions src/observe.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { ObserverInstanceCallback } from "./index";
import type {
IntersectionObserverInitWithOptions,
ObserverInstanceCallback,
} from "./index";

const observerMap = new Map<
string,
Expand Down Expand Up @@ -28,7 +31,7 @@ export function defaultFallbackInView(inView: boolean | undefined) {
* Generate a unique ID for the root element
* @param root
*/
function getRootId(root: IntersectionObserverInit["root"]) {
function getRootId(root: IntersectionObserverInitWithOptions["root"]) {
if (!root) return "0";
if (RootIds.has(root)) return RootIds.get(root);
rootId += 1;
Expand All @@ -41,23 +44,24 @@ function getRootId(root: IntersectionObserverInit["root"]) {
* Ensures we can reuse the same observer when observing elements with the same options.
* @param options
*/
export function optionsToId(options: IntersectionObserverInit) {
export function optionsToId(options: IntersectionObserverInitWithOptions) {
return Object.keys(options)
.sort()
.filter(
(key) => options[key as keyof IntersectionObserverInit] !== undefined,
(key) =>
options[key as keyof IntersectionObserverInitWithOptions] !== undefined,
)
.map((key) => {
return `${key}_${
key === "root"
? getRootId(options.root)
: options[key as keyof IntersectionObserverInit]
: options[key as keyof IntersectionObserverInitWithOptions]
}`;
})
.toString();
}

function createObserver(options: IntersectionObserverInit) {
function createObserver(options: IntersectionObserverInitWithOptions) {
// Create a unique ID for this observer instance, based on the root, root margin and threshold.
const id = optionsToId(options);
let instance = observerMap.get(id);
Expand Down Expand Up @@ -118,7 +122,7 @@ function createObserver(options: IntersectionObserverInit) {
export function observe(
element: Element,
callback: ObserverInstanceCallback,
options: IntersectionObserverInit = {},
options: IntersectionObserverInitWithOptions = {},
fallbackInView = unsupportedValue,
) {
if (
Expand Down
7 changes: 6 additions & 1 deletion src/test-utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import * as React from "react";
import * as DeprecatedReactTestUtils from "react-dom/test-utils";

type IntersectionObserverWithScrollMargin = IntersectionObserver & {
scrollMargin: string;
};

type Item = {
callback: IntersectionObserverCallback;
elements: Set<Element>;
Expand Down Expand Up @@ -121,12 +125,13 @@ export function setupIntersectionMocking(mockFn: typeof vi.fn) {
elements: new Set<Element>(),
created: Date.now(),
};
const instance: IntersectionObserver = {
const instance: IntersectionObserverWithScrollMargin = {
thresholds: Array.isArray(options.threshold)
? options.threshold
: [options.threshold ?? 0],
root: options.root ?? null,
rootMargin: options.rootMargin ?? "",
scrollMargin: options.scrollMargin ?? "",
observe: mockFn((element: Element) => {
item.elements.add(element);
}),
Expand Down
4 changes: 3 additions & 1 deletion src/useInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export function useInView({
delay,
trackVisibility,
rootMargin,
scrollMargin,
root,
triggerOnce,
skip,
Expand Down Expand Up @@ -93,8 +94,8 @@ export function useInView({
{
root,
rootMargin,
scrollMargin,
threshold,
// @ts-expect-error
trackVisibility,
delay,
},
Expand All @@ -115,6 +116,7 @@ export function useInView({
ref,
root,
rootMargin,
scrollMargin,
triggerOnce,
skip,
trackVisibility,
Expand Down
3 changes: 3 additions & 0 deletions src/useOnInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export const useOnInView = <TElement extends Element>(
threshold,
root,
rootMargin,
scrollMargin,
trackVisibility,
delay,
triggerOnce,
Expand Down Expand Up @@ -116,6 +117,7 @@ export const useOnInView = <TElement extends Element>(
threshold,
root,
rootMargin,
scrollMargin,
trackVisibility,
delay,
} as IntersectionObserverInit,
Expand All @@ -140,6 +142,7 @@ export const useOnInView = <TElement extends Element>(
Array.isArray(threshold) ? threshold.toString() : threshold,
root,
rootMargin,
scrollMargin,
trackVisibility,
delay,
triggerOnce,
Expand Down
9 changes: 8 additions & 1 deletion storybook/stories/InView.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ export const Basic: Story = {
},
};

export const WithRootMarginexport: Story = {
export const WithRootMargin: Story = {
args: {
rootMargin: "25px 0px",
threshold: 0,
},
};

export const WithScrollMargin: Story = {
args: {
scrollMargin: "25px",
threshold: 0,
},
};

export const StartInView: Story = {
args: {
threshold: 0,
Expand Down
6 changes: 6 additions & 0 deletions storybook/stories/story-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const argTypes: ArgTypes<IntersectionOptions> = {
"Margin around the root. Can have values similar to the CSS margin property, e.g. `10px 20px 30px 40px` (top, right, bottom, left).",
defaultValue: "0px",
},
scrollMargin: {
control: { type: "text" },
description:
"Margin around nested scroll containers that clip the target, including the root if it is a scroll container.",
defaultValue: "0px",
},
threshold: {
control: {
type: "range",
Expand Down
6 changes: 6 additions & 0 deletions storybook/stories/useInView.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export const WithRootMargin: Story = {
},
};

export const WithScrollMargin: Story = {
args: {
scrollMargin: "25px",
},
};

export const TallerThanViewport: Story = {
args: {
style: { minHeight: "150vh" },
Expand Down
Loading