A lightweight HTMX extension that integrates the Intersection Observer API to simplify scroll-based web experiences like lazy loading, infinite scroll, and visibility tracking.
- 🚀 Simple API - Just add attributes to your HTML
- 🎯 Lazy Loading - Load content only when visible
- ♾️ Infinite Scroll - Automatically load more content
- 👁️ Visibility Tracking - Track when elements enter/exit viewport
- 🎨 No Dependencies - Works with vanilla HTMX
- ⚡ Performant - Uses native IntersectionObserver API
- 🔄 Reusable Observers - Shares observers across elements with same config
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<script src="htmx-intersect.js"></script>npm install htmx-intersectimport 'htmx-intersect';<div hx-ext="intersect"
hx-get="/api/content"
hx-trigger="intersect">
This content will load when scrolled into view
</div><img hx-ext="intersect"
hx-get="/api/image/123"
hx-trigger="intersect once"
hx-swap="outerHTML"
src="placeholder.jpg"
alt="Lazy loaded image"><div id="content">
<!-- Your content here -->
</div>
<!-- Trigger element at the bottom -->
<div hx-ext="intersect"
hx-get="/api/more?page=2"
hx-trigger="intersect once"
hx-target="#content"
hx-swap="beforeend"
intersect-threshold="0.5">
Loading more...
</div>Triggers an HTMX request when the element intersects with the viewport.
Modifiers:
once- Trigger only the first time (perfect for lazy loading)- Example:
hx-trigger="intersect once"
Specifies the root element to observe intersection against. If not set or set to null or viewport, uses the browser viewport.
<div intersect-root="#scrollContainer">
Observes intersection within #scrollContainer
</div>The percentage of the element that must be visible to trigger. Can be:
- Single value:
0.5(50% visible) - Multiple values:
0,0.25,0.5,0.75,1(triggers at each threshold)
<!-- Trigger when 50% visible -->
<div intersect-threshold="0.5">
<!-- Trigger at 0%, 50%, and 100% -->
<div intersect-threshold="0,0.5,1.0">Default: 0 (triggers as soon as any pixel is visible)
Margin around the root element (similar to CSS margin). Positive values expand the root's area, negative values shrink it.
<!-- Load content 200px before it enters viewport -->
<div intersect-margin="200px 0px 0px 0px">
<!-- Percentage-based -->
<div intersect-margin="10%">Default: "0px"
Margin around nested scroll containers. Useful when you have scrollable elements within the root.
<div intersect-scroll-margin="50px">Default: "0px"
Controls whether and how to unload/remove elements when they exit the viewport. Great for memory management in infinite scroll scenarios.
Values:
"true"or"remove"- Completely remove element from DOM"content"- Remove only innerHTML, keep element shell (content restored on re-entry)"hide"- Setdisplay: none(faster than removal)"false"or omit - No unloading (default)
<!-- Remove element when it exits viewport -->
<div intersect-unload="true">
<!-- Just hide the element -->
<div intersect-unload="hide">
<!-- Remove content but keep element -->
<div intersect-unload="content"
intersect-unload-placeholder="<div>Loading...</div>">Default: Not set (no unloading)
Delay in milliseconds before unloading. Prevents flickering when scrolling quickly.
<!-- Wait 2 seconds before unloading -->
<div intersect-unload="true"
intersect-unload-delay="2000">Default: 0 (immediate)
HTML to show when using intersect-unload="content". Only used with content mode.
<div intersect-unload="content"
intersect-unload-placeholder="<div class='skeleton'>Loading...</div>">The extension emits custom events you can listen to:
Fired when element enters the viewport.
element.addEventListener('intersect:enter', (event) => {
console.log('Element entered!', event.detail);
// detail: { ratio, time, bounds }
});Fired when element exits the viewport.
element.addEventListener('intersect:exit', (event) => {
console.log('Element exited!', event.detail);
// detail: { ratio, time }
});Continuously fired with visibility updates.
element.addEventListener('intersect:visible', (event) => {
console.log('Visibility ratio:', event.detail.ratio);
// detail: { ratio, isIntersecting }
});Fired before an element is unloaded (when using intersect-unload). Can be prevented.
element.addEventListener('intersect:beforeunload', (event) => {
console.log('About to unload', event.detail.mode);
// Prevent unloading if needed
if (shouldKeepElement) {
event.preventDefault();
}
});Fired after an element is unloaded. Fired on the parent element.
parent.addEventListener('intersect:unload', (event) => {
console.log('Element unloaded:', event.detail.element);
// detail: { mode, element }
});<div class="lazy-component"
hx-ext="intersect"
hx-get="/components/widget"
hx-trigger="intersect once"
intersect-threshold="0.1">
<div class="skeleton-loader">Loading...</div>
</div><div id="posts">
<!-- Posts loaded here -->
</div>
<div hx-ext="intersect"
hx-get="/api/posts"
hx-trigger="intersect once"
hx-target="#posts"
hx-swap="beforeend"
hx-indicator="#loading"
intersect-margin="300px 0px 0px 0px">
<div id="loading" class="htmx-indicator">
<span>Loading more posts...</span>
</div>
</div><div hx-ext="intersect"
hx-post="/analytics/view"
hx-trigger="intersect once"
intersect-threshold="0.5"
data-content-id="article-123">
Article content here
</div><picture hx-ext="intersect"
hx-get="/images/high-res/photo.jpg"
hx-trigger="intersect once"
hx-swap="outerHTML"
intersect-margin="100px">
<img src="low-res-placeholder.jpg" alt="Photo">
</picture><video hx-ext="intersect"
src="video.mp4"
data-hx-on:intersect:enter="this.play()"
data-hx-on:intersect:exit="this.pause()">
</video><div id="header"
hx-ext="intersect"
intersect-threshold="0,1"
data-hx-on:intersect:visible="
if (event.detail.ratio < 1) {
this.classList.add('sticky');
} else {
this.classList.remove('sticky');
}
">
Header content
</div><div id="scrollContainer" style="height: 400px; overflow-y: auto;">
<div hx-ext="intersect"
hx-get="/nested/content"
hx-trigger="intersect once"
intersect-root="#scrollContainer"
intersect-threshold="0.5">
Nested scrollable content
</div>
</div><div id="posts"></div>
<!-- Load new content -->
<div hx-ext="intersect"
hx-get="/api/posts?page=2"
hx-trigger="intersect once"
hx-target="#posts"
hx-swap="beforeend"
intersect-margin="500px">
Loading more...
</div>
<!-- Inside each post, enable unloading -->
<div class="post"
hx-ext="intersect"
intersect-unload="content"
intersect-unload-delay="1000"
intersect-unload-placeholder="<div class='skeleton'>Post removed from memory</div>">
Post content here...
</div><!-- Each item unloads when far from viewport -->
<div class="list-item"
hx-ext="intersect"
hx-trigger="intersect"
intersect-unload="content"
intersect-margin="1000px"
intersect-unload-placeholder="<div class='placeholder'>Item #{id}</div>">
Heavy content here...
</div><div class="advertisement"
hx-ext="intersect"
hx-post="/analytics/ad-viewed"
hx-trigger="intersect once"
intersect-threshold="0.5"
intersect-unload="true"
intersect-unload-delay="5000">
Ad content (removed 5s after leaving viewport)
</div>For complex scenarios, you can use the JavaScript API:
// Start observing an element
htmx.intersect.observe(element);
// Stop observing
htmx.intersect.unobserve(element);
// Create custom observer
const observer = htmx.intersect.createObserver(
{
root: null,
rootMargin: '0px',
threshold: [0, 0.5, 1]
},
(entries) => {
entries.forEach(entry => {
console.log('Intersection:', entry);
});
}
);
observer.observe(element);<!-- Intersect + Polling -->
<div hx-ext="intersect"
hx-get="/live-data"
hx-trigger="intersect once, every 5s">
Start polling when visible
</div>
<!-- Intersect + WebSocket -->
<div hx-ext="intersect,ws"
ws-connect="/live-feed"
hx-trigger="intersect once">
Connect to WebSocket when visible
</div>
<!-- Intersect + Animation -->
<div hx-ext="intersect"
class="fade-in-element"
data-hx-on:intersect:enter="this.classList.add('visible')">
Animated content
</div>The extension automatically adds/removes the intersecting class:
.my-element {
opacity: 0;
transform: translateY(50px);
transition: all 0.6s ease;
}
.my-element.intersecting {
opacity: 1;
transform: translateY(0);
}Works in all browsers that support:
- HTMX 1.9+
- IntersectionObserver API (all modern browsers)
For older browsers, consider using a polyfill.
- Use
oncemodifier for one-time loads to automatically clean up observers - Set appropriate thresholds - Don't use too many threshold values
- Use root margins wisely - Preload content just before it's needed
- Shared observers - Elements with identical configs share observers
- Ensure
hx-trigger="intersect"is set - Check that element has non-zero dimensions
- Verify
intersect-thresholdis appropriate - Check browser console for errors
- Add
oncemodifier:hx-trigger="intersect once" - Or use
intersect-thresholdto be more specific
- Set
intersect-rootto the scroll container - Consider using
intersect-scroll-margin
Contributions welcome! Please open an issue or PR.
MIT License - see LICENSE file for details
Built with ❤️ for the HTMX community