Skip to main content

WindowBoundary

A performance-optimized WindowBoundary component that detects when elements enter or exit the viewport using the Intersection Observer API. It is perfect for lazy loading, infinite scrolling, triggering animations, and other performance optimizations.


Props

NameTypeDefaultDescription
onItemEnter(element: Element | null) => voidCallback fired every time the element enters the viewport.
onItemExit(element: Element | null) => voidCallback fired every time the element exits the viewport.
onceItemEnter(element: Element | null) => voidCallback that fires only once when the element first enters the viewport.
onceItemExit(element: Element | null) => voidCallback that fires only once when the element first exits the viewport.
rootElement | Document | nulldocument.bodyThe element used as the viewport for checking visibility. Defaults to the browser viewport.
AsElementType"div"The HTML tag or React component to render as the wrapper element.
thresholdnumber0A number between 0 and 1 indicating the percentage of the element's visibility needed to trigger callbacks.

Note The component is headless and does not have any visual styles of its own.


Usage

Basic Visibility Detection

Use the onItemEnter and onItemExit callbacks to track when an element becomes visible.

function VisibilityTracker() {
const [visible, setVisible] = useState(false);

return (
<div style={{ height: '150vh', paddingTop: '75vh' }}>
<WindowBoundary
onItemEnter={() => setVisible(true)}
onItemExit={() => setVisible(false)}
>
<div style={{
padding: '40px',
textAlign: 'center',
background: visible ? 'lightgreen' : '#eee',
transition: 'background 0.3s ease'
}}>
{visible ? 'I am in the viewport!' : 'Scroll me into view!'}
</div>
</WindowBoundary>
</div>
);
}

Preview

Scroll me into view!

Lazy Loading Images

Use onceItemEnter to defer loading an image until it is about to enter the viewport. This saves bandwidth and improves initial page load time.

function LazyImage() {
const [isInView, setIsInView] = useState(false);

return (
<div style={{ height: '150vh', paddingTop: '75vh' }}>
<WindowBoundary onceItemEnter={() => setIsInView(true)} threshold={0.1}>
<div style={{ minHeight: '200px', background: '#f0f0f0', display: 'grid', placeItems: 'center' }}>
{isInView ? <img src="https://via.placeholder.com/400x200.png?text=Image+Loaded" alt="Lazy Loaded" /> : 'Scroll down to load image...'}
</div>
</WindowBoundary>
</div>
);
}

Preview

Scroll down to load image...

Infinite Scrolling

Place the WindowBoundary at the end of a list to trigger a function that loads more items when the user scrolls to the bottom.

function InfiniteList() {
const [items, setItems] = useState(Array.from({ length: 5 }, (_, i) => `Item ${i + 1}`));
const [loading, setLoading] = useState(false);

const loadMore = () => {
if (loading) return;
setLoading(true);
setTimeout(() => {
const newItems = Array.from({ length: 5 }, (_, i) => `Item ${items.length + i + 1}`);
setItems(prev => [...prev, ...newItems]);
setLoading(false);
}, 1000);
};

return (
<div style={{ height: '300px', overflowY: 'auto', border: '1px solid #ccc' }}>
{items.map(item => <div key={item}>{item}</div>)}
<WindowBoundary onItemEnter={loadMore} threshold={1.0}>
<div>{loading ? 'Loading...' : 'Scroll to load more'}</div>
</WindowBoundary>
</div>
);
}

Preview

Item 1
Item 2
Item 3
Item 4
Item 5
Scroll to load more

Styling

WindowBoundary is a headless component, meaning it has no visual output or styles. It renders a wrapper element (a div by default) around its children, to which it attaches the Intersection Observer. You can change the wrapper element using the As prop.

Any styles should be applied to the child elements you pass to the component.


Types (reference)

import { ElementType } from "react";

export type WindowBoundaryProps = {
onItemEnter?: (element: Element | null) => void;
onItemExit?: (element: Element | null) => void;
onceItemEnter?: (element: Element | null) => void;
onceItemExit?: (element: Element | null) => void;
root?: Element | Document | null;
As?: ElementType;
threshold?: number;
};