From 32f8da699bd2324e6ea3d5e8477f9e5758d79899 Mon Sep 17 00:00:00 2001
From: Daniel Schmidt <3764345+thebuilder@users.noreply.github.com>
Date: Thu, 9 Jul 2026 09:19:18 +0200
Subject: [PATCH 1/2] Address scrollMargin review feedback
---
README.md | 1 +
src/InView.tsx | 4 +++-
src/__tests__/InView.test.tsx | 10 ++++++++++
src/__tests__/observe.test.ts | 4 ++--
src/__tests__/useInView.test.tsx | 10 ++++++++++
src/index.tsx | 14 +++++++++++++-
src/observe.ts | 18 +++++++++++-------
src/test-utils.ts | 7 ++++++-
src/useInView.tsx | 4 +++-
src/useOnInView.tsx | 3 +++
storybook/stories/InView.story.tsx | 9 ++++++++-
storybook/stories/story-utils.ts | 6 ++++++
storybook/stories/useInView.story.tsx | 6 ++++++
13 files changed, 82 insertions(+), 14 deletions(-)
diff --git a/README.md b/README.md
index 8492dd2f..885ed9fb 100644
--- a/README.md
+++ b/README.md
@@ -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, including the root 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. |
diff --git a/src/InView.tsx b/src/InView.tsx
index 300723f6..833a00c5 100644
--- a/src/InView.tsx
+++ b/src/InView.tsx
@@ -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 ||
@@ -109,6 +110,7 @@ export class InView extends React.Component<
threshold,
root,
rootMargin,
+ scrollMargin,
trackVisibility,
delay,
fallbackInView,
@@ -124,7 +126,7 @@ export class InView extends React.Component<
threshold,
root,
rootMargin,
- // @ts-expect-error
+ scrollMargin,
trackVisibility,
delay,
},
diff --git a/src/__tests__/InView.test.tsx b/src/__tests__/InView.test.tsx
index 204401e0..213a51cf 100644
--- a/src/__tests__/InView.test.tsx
+++ b/src/__tests__/InView.test.tsx
@@ -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(Inner);
+ mockAllIsIntersecting(true);
+ const instance = intersectionMockInstance(container.children[0]);
+ vi.spyOn(instance, "unobserve");
+
+ rerender(Inner);
+ expect(instance.unobserve).toHaveBeenCalled();
+});
+
test("Should unobserve when triggerOnce comes into view", () => {
const { container } = render(Inner);
mockAllIsIntersecting(false);
diff --git a/src/__tests__/observe.test.ts b/src/__tests__/observe.test.ts
index f3853005..40841e01 100644
--- a/src/__tests__/observe.test.ts
+++ b/src/__tests__/observe.test.ts
@@ -37,7 +37,6 @@ test("should convert options to id", () => {
expect(
optionsToId({
threshold: 0,
- // @ts-expect-error
trackVisibility: true,
delay: 500,
}),
@@ -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"`);
});
diff --git a/src/__tests__/useInView.test.tsx b/src/__tests__/useInView.test.tsx
index d7f7d95c..cc664e86 100644
--- a/src/__tests__/useInView.test.tsx
+++ b/src/__tests__/useInView.test.tsx
@@ -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(
+ ,
+ );
+ const wrapper = getByTestId("wrapper");
+ const instance = intersectionMockInstance(wrapper);
+
+ expect(instance).toHaveProperty("scrollMargin", "10px");
+});
+
test("should create a lazy hook", () => {
const { getByTestId } = render();
const wrapper = getByTestId("wrapper");
diff --git a/src/index.tsx b/src/index.tsx
index 4afd36bb..91f17554 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -9,6 +9,15 @@ export { useOnInView } from "./useOnInView";
type Omit = Pick>;
+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,
@@ -26,11 +35,14 @@ interface RenderProps {
ref: React.RefObject | ((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 */
diff --git a/src/observe.ts b/src/observe.ts
index 1113ba1e..0e84176b 100644
--- a/src/observe.ts
+++ b/src/observe.ts
@@ -1,4 +1,7 @@
-import type { ObserverInstanceCallback } from "./index";
+import type {
+ IntersectionObserverInitWithOptions,
+ ObserverInstanceCallback,
+} from "./index";
const observerMap = new Map<
string,
@@ -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;
@@ -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);
@@ -118,7 +122,7 @@ function createObserver(options: IntersectionObserverInit) {
export function observe(
element: Element,
callback: ObserverInstanceCallback,
- options: IntersectionObserverInit = {},
+ options: IntersectionObserverInitWithOptions = {},
fallbackInView = unsupportedValue,
) {
if (
diff --git a/src/test-utils.ts b/src/test-utils.ts
index 4dcdba69..3e3f5572 100644
--- a/src/test-utils.ts
+++ b/src/test-utils.ts
@@ -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;
@@ -121,12 +125,13 @@ export function setupIntersectionMocking(mockFn: typeof vi.fn) {
elements: new Set(),
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);
}),
diff --git a/src/useInView.tsx b/src/useInView.tsx
index 3a0dc60f..791c5014 100644
--- a/src/useInView.tsx
+++ b/src/useInView.tsx
@@ -38,6 +38,7 @@ export function useInView({
delay,
trackVisibility,
rootMargin,
+ scrollMargin,
root,
triggerOnce,
skip,
@@ -93,8 +94,8 @@ export function useInView({
{
root,
rootMargin,
+ scrollMargin,
threshold,
- // @ts-expect-error
trackVisibility,
delay,
},
@@ -115,6 +116,7 @@ export function useInView({
ref,
root,
rootMargin,
+ scrollMargin,
triggerOnce,
skip,
trackVisibility,
diff --git a/src/useOnInView.tsx b/src/useOnInView.tsx
index 229a6769..5e9069c0 100644
--- a/src/useOnInView.tsx
+++ b/src/useOnInView.tsx
@@ -49,6 +49,7 @@ export const useOnInView = (
threshold,
root,
rootMargin,
+ scrollMargin,
trackVisibility,
delay,
triggerOnce,
@@ -116,6 +117,7 @@ export const useOnInView = (
threshold,
root,
rootMargin,
+ scrollMargin,
trackVisibility,
delay,
} as IntersectionObserverInit,
@@ -140,6 +142,7 @@ export const useOnInView = (
Array.isArray(threshold) ? threshold.toString() : threshold,
root,
rootMargin,
+ scrollMargin,
trackVisibility,
delay,
triggerOnce,
diff --git a/storybook/stories/InView.story.tsx b/storybook/stories/InView.story.tsx
index 92be144b..1cd0bb73 100644
--- a/storybook/stories/InView.story.tsx
+++ b/storybook/stories/InView.story.tsx
@@ -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,
diff --git a/storybook/stories/story-utils.ts b/storybook/stories/story-utils.ts
index 2af7a51e..e7ae1263 100644
--- a/storybook/stories/story-utils.ts
+++ b/storybook/stories/story-utils.ts
@@ -13,6 +13,12 @@ export const argTypes: ArgTypes = {
"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",
diff --git a/storybook/stories/useInView.story.tsx b/storybook/stories/useInView.story.tsx
index 0ee00d7c..3a54b32a 100644
--- a/storybook/stories/useInView.story.tsx
+++ b/storybook/stories/useInView.story.tsx
@@ -109,6 +109,12 @@ export const WithRootMargin: Story = {
},
};
+export const WithScrollMargin: Story = {
+ args: {
+ scrollMargin: "25px",
+ },
+};
+
export const TallerThanViewport: Story = {
args: {
style: { minHeight: "150vh" },
From a69589e6372bacba08ff7ed08a7a9e2e4a1d8d63 Mon Sep 17 00:00:00 2001
From: Daniel Schmidt
Date: Thu, 9 Jul 2026 09:28:13 +0200
Subject: [PATCH 2/2] Document scrollMargin support in InView
---
README.md | 7 ++++++-
src/InView.tsx | 1 +
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/README.md b/README.md
index 885ed9fb..426254aa 100644
--- a/README.md
+++ b/README.md
@@ -206,7 +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, including the root if it is a scroll container. |
+| **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. |
@@ -299,9 +299,14 @@ When using `rootMargin`, the margin gets added to the current `root` - If your
application is running inside a `