Scroll Animations and Lazy Loading
x-intersect is the Alpine.js directive that turns the browser's IntersectionObserver API into a declarative HTML directive. It turns scroll animations, image lazy loading, one-time triggers and observed sections into a pure HTML task, without a single line of manual JavaScript observer code.
Table of Contents
- 1. x-intersect and the IntersectionObserver
- 2. enter and leave: reacting precisely to visibility
- 3. Scroll animations with Tailwind CSS and x-intersect
- 4. Lazy loading images and content
- 5. once and threshold: one-time triggers and trigger threshold
- 6. In practice: animating counters and statistics on scroll
- 7. Performance: x-intersect vs. a manual scroll listener
- 8. x-intersect modifiers compared side by side
- 9. Summary
- 10. FAQ
1. x-intersect and the IntersectionObserver
The browser native IntersectionObserver watches when an element enters or leaves the viewport, without requiring a scroll event listener. Alpine.js wraps this observer in the x-intersect directive, available as a plugin since Alpine.js 3.x. Instead of instantiating an observer yourself, registering a callback function and cleaning up the observer on destroy, you simply write x-intersect="visible = true" on an element and Alpine takes care of everything else.
To use x-intersect, the plugin must either be added as a script tag via the CDN variant or installed via npm and registered as an Alpine plugin. With the CDN bundle, x-intersect is already included. The directive accepts any Alpine.js expression and runs that expression exactly when the element becomes visible. That opens up possibilities for animations, tracking, data loading and much more, without a single line of imperative observer code.
// Installation via npm
import Alpine from 'alpinejs';
import intersect from '@alpinejs/intersect';
Alpine.plugin(intersect);
Alpine.start();
// Alternatively via CDN, x-intersect is included in the CDN bundle:
// <script src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"></script>
// <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
The basic form x-intersect="expression" is shorthand for x-intersect:enter="expression" and only runs when the element enters the viewport. In practice that is enough for most scroll animation scenarios, where an element should fade in as soon as the user scrolls to it. For more advanced cases, such as reversing an animation when an element leaves the viewport, or reacting only when scrolling down, the :enter and :leave modifiers come into play.
2. enter and leave: reacting precisely to visibility
x-intersect:enter fires when the element enters the viewport. x-intersect:leave fires when it leaves. Both modifiers can be combined on the same element to build bidirectional animations: fade the element in on enter, fade it out on leave. That is the basic principle behind many sticky header indicators, progress bars and reading progress trackers.
An important distinction: without a modifier, x-intersect runs the expression on first entry and again on every subsequent entry, it is not a one-time trigger. Anyone who wants a one-time trigger combines x-intersect with the .once modifier. Anyone who wants to react to every enter and leave instead combines :enter and :leave. The expression does not get automatic access to the IntersectionObserverEntry object; anyone who needs to determine scroll direction has to track the scroll position manually or fall back on an Alpine store.
<!-- Simple enter example: add a class when visible -->
<div
x-data="{ visible: false }"
x-intersect:enter="visible = true"
x-intersect:leave="visible = false"
:class="visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'"
class="transition-all duration-700 ease-out bg-white rounded-xl p-6 shadow"
>
This block fades in on enter and fades out on leave.
</div>
<!-- One-time trigger: animation only once on first appearance -->
<div
x-data="{ visible: false }"
x-intersect.once="visible = true"
:class="visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95'"
class="transition-all duration-500 ease-out"
>
Animated once only, no reset on repeated scrolling.
</div>
3. Scroll animations with Tailwind CSS and x-intersect
Combining x-intersect with Tailwind CSS transition classes is the cleanest pattern for scroll animations without a CSS animation library. The principle: the element starts in an invisible state (Tailwind classes for opacity-0, translate-y-8 etc.), and as soon as x-intersect fires, an Alpine variable is set that switches the transition classes via a :class binding. Tailwind's own transition utilities (transition-all, duration-700, ease-out) handle the smooth animation entirely in CSS, no requestAnimationFrame, no JavaScript animation loop.
For staggered animations of multiple elements, an Alpine array pattern is recommended: elements in an array rendered via x-for, each with its own index used as a CSS delay through :style="'transition-delay: ' + (index * 100) + 'ms'". That produces cascading fade-in animations that reveal several cards or list items one after another as they enter the viewport, entirely declarative, without any JavaScript timing code.
<!-- Staggered card animation -->
<div x-data="{
cards: [
{ title: 'Card 1', text: 'Content A' },
{ title: 'Card 2', text: 'Content B' },
{ title: 'Card 3', text: 'Content C' },
],
visible: []
}">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
<template x-for="(card, i) in cards" :key="i">
<div
x-intersect.once="visible.push(i)"
:class="visible.includes(i) ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'"
:style="'transition: all 0.6s ease-out; transition-delay: ' + (i * 150) + 'ms'"
class="bg-white rounded-xl p-6 shadow-md"
>
<h3 x-text="card.title" class="font-bold text-slate-900 mb-2"></h3>
<p x-text="card.text" class="text-slate-600 text-sm"></p>
</div>
</template>
</div>
</div>
4. Lazy loading images and content
Lazy loading with x-intersect is a common use case that noticeably improves initial load time, especially on image heavy pages. The pattern: images are initially rendered without a src attribute (or with a placeholder image), and as soon as the image element enters the viewport, x-intersect sets the real src attribute via an Alpine binding. The browser only loads the image once it is actually needed.
For content lazy loading, meaning loading HTML content on demand via fetch, x-intersect is combined with Alpine's $el reference or a store. On entering, the element registers a fetch request, sets a loading state and replaces its content once the request completes. For very complex content the htmx pattern is worth considering, but for simple API calls Alpine's built-in fetch is sufficient and keeps the number of dependencies minimal.
<!-- Image lazy load with x-intersect -->
<div x-data="{ loaded: false, src: '/images/hero.jpg' }">
<img
x-intersect.once="loaded = true"
:src="loaded ? src : 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'"
:class="loaded ? 'opacity-100' : 'opacity-0'"
class="w-full h-64 object-cover rounded-xl transition-opacity duration-500"
alt="Hero image"
width="800"
height="400"
>
</div>
<!-- Content lazy load: section is only loaded on demand -->
<div x-data="{
content: null,
loading: false,
async loadContent() {
this.loading = true;
const r = await fetch('/api/testimonials');
this.content = await r.json();
this.loading = false;
}
}">
<div x-intersect.once="loadContent()">
<div x-show="loading" class="animate-pulse h-24 bg-slate-100 rounded-xl"></div>
<template x-if="content">
<ul>
<template x-for="item in content" :key="item.id">
<li x-text="item.text" class="py-2 border-b border-slate-100"></li>
</template>
</ul>
</template>
</div>
</div>
5. once and threshold: one-time triggers and trigger threshold
The .once modifier is the right choice for scroll animations in most cases: it makes sure the animation only fires the first time the element appears in the viewport and is never reset afterward. That matches typical user expectations: once content has faded in, it should stay visible, even if the user scrolls up and down again.
The .half modifier is a shortcut for threshold: 0.5 and means the expression only fires once 50% of the element is visible. That prevents animations on large elements from triggering too early, before their main content has actually entered the viewport. For custom thresholds such as 20% or 75%, use .threshold.20 (that is, x-intersect.threshold.20); Alpine passes the value as threshold: 0.20 to the underlying IntersectionObserver.
6. In practice: animating counters and statistics on scroll
Animated number counters are a classic UI pattern on landing pages and in statistics sections. The user scrolls to a set of key figures, and the numbers count up from 0 to their target value, a visually striking pattern that grabs attention and reinforces the core message. With x-intersect and a plain Alpine.js counter with no external library, this pattern can be built in just a few lines.
The basic principle behind the counter: an animateCounter function calculates the current value via requestAnimationFrame based on elapsed time and an easing algorithm. x-intersect triggers this function once (.once) as soon as the statistics section enters the viewport. The result is a smoothly animated counter that adapts precisely to the browser's refresh rate and needs no external library.
<!-- Animated number counter with x-intersect -->
<div x-data="{
stats: [
{ label: 'Projects', target: 142, suffix: '+', current: 0 },
{ label: 'Clients', target: 87, suffix: '', current: 0 },
{ label: 'Uptime', target: 99.9, suffix: '%', current: 0 },
],
started: false,
startAll() {
if (this.started) return;
this.started = true;
this.stats.forEach((stat, i) => this.animateCounter(i));
},
animateCounter(index) {
const duration = 1800;
const start = performance.now();
const target = this.stats[index].target;
const step = (now) => {
const elapsed = now - start;
const progress = Math.min(elapsed / duration, 1);
// Ease-out cubic
const eased = 1 - Math.pow(1 - progress, 3);
this.stats[index].current = Math.round(eased * target * 10) / 10;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
}">
<div x-intersect.once="startAll()" class="grid grid-cols-3 gap-8 text-center py-12">
<template x-for="stat in stats" :key="stat.label">
<div>
<div class="text-4xl font-black" style="color:#5eead4;">
<span x-text="stat.current"></span><span x-text="stat.suffix"></span>
</div>
<div class="text-sm text-slate-500 mt-1" x-text="stat.label"></div>
</div>
</template>
</div>
</div>
7. Performance: x-intersect vs. a manual scroll listener
The manual scroll event listener is the historical alternative to the IntersectionObserver, and therefore to x-intersect. The difference is fundamental: a scroll event listener fires on every scrolled pixel, the browser has to run the callback function synchronously, and that costs main thread time that can directly translate into jank while scrolling. Even with a throttle or debounce wrapper, it remains a reactive, main thread blocking pattern.
The IntersectionObserver, and with it x-intersect, works fundamentally differently: the browser decides for itself when to check visibility, and runs the callbacks outside the critical scroll path. In modern browsers the intersection calculation even runs partly off the main thread. The result: no jank while scrolling, even with dozens of elements being observed at once. If Lighthouse is the yardstick, it consistently rewards IntersectionObserver based lazy loading with measurable improvements in the LCP and TBT performance metrics.
8. x-intersect modifiers compared side by side
The various x-intersect modifiers cover different use cases. Choosing the right modifier is what decides whether the animation feels natural or ends up irritating the user.
| Modifier | Behavior | Typical use case | Note |
|---|---|---|---|
x-intersect |
Fires on every entry | Repeatable triggers, tracking | Alias for :enter without .once |
x-intersect.once |
Fires only on first entry | Scroll animations, lazy load | Observer is removed afterward |
x-intersect:enter |
Fires on entry | Fade in on scroll | Combinable with :leave |
x-intersect:leave |
Fires on leave | Fade out, sticky indicators | Combinable with :enter |
x-intersect.half |
Threshold: 50% visible | Large hero sections | Shortcut for threshold: 0.5 |
x-intersect.threshold.75 |
Threshold: 75% visible | Read tracking, video autoplay | Any percentage 0 to 100 |
The combination x-intersect:enter.once is the right choice for the vast majority of scroll animations on marketing pages: fade in once, then stop observing. For reading progress trackers and video autoplay patterns, x-intersect:enter plus x-intersect:leave without .once is the more suitable variant, because the state needs to change dynamically with scroll position.
Mironsoft
Alpine.js frontend development for Magento 2 and Hyva themes
Need scroll animations and lazy loading for your Hyva theme?
We implement performant scroll animations with x-intersect, optimize load time with lazy loading and build Alpine.js components that integrate cleanly into Hyva themes, without external libraries.
Scroll animations
x-intersect plus Tailwind CSS for smooth fade-in and staggered animations
Lazy loading
Load images and content only when needed, measurable LCP improvement
Hyva integration
Alpine.js components cleanly wired into Hyva themes via layout XML
9. Summary
x-intersect is the cleanest and most performant way to react to element visibility in the viewport: fully declarative, with no manual observer code and no scroll event listener. The .once, :enter, :leave, .half and .threshold.N modifiers cover every practically relevant scenario, from simple fade-in animations to bidirectional visible states to precise lazy loading with a configurable trigger threshold.
For Hyva themes, x-intersect is especially valuable because it uses the full Alpine.js ecosystem without needing jQuery or an external animation library. Combined with Tailwind CSS transitions and Alpine's reactive state management, it forms a pattern that integrates into existing Hyva components in a maintainable, readable and extensible way. Lazy loading images measurably improves LCP, a direct SEO and UX benefit achievable without additional dependencies.
x-intersect in Alpine.js: the essentials at a glance
Basic principle
x-intersect wraps the IntersectionObserver in a declarative Alpine directive. No manual observer code, no scroll event listener, no jank while scrolling.
Animations
x-intersect.once plus Tailwind transitions for one-time fade-in animations. Staggered animations via CSS transition-delay inside the x-for loop.
Lazy loading
Render images without src, set it on entry. Load content via fetch only once visible. Measurably improves LCP and initial load time.
Choosing a modifier
.once for one-time animations. :enter/:leave for bidirectional state. .half or .threshold.N for precise trigger thresholds on large elements.
10. FAQ: Alpine.js x-intersect
1What does x-intersect do in Alpine.js?
2How do I install x-intersect?
npm install @alpinejs/intersect and Alpine.plugin(intersect) before Alpine.start(). Via CDN, x-intersect is already included in the bundle.3Difference between x-intersect and x-intersect.once?
4How do I react to an element leaving the viewport?
x-intersect:leave="expression" on the same element. Combined with x-intersect:enter it produces a bidirectional visible state.5What does .half mean on x-intersect?
6Staggered animations with x-intersect?
:style="'transition-delay: ' + (i * 150) + 'ms'", cascading fade-in animations without JS timing code.7Why is x-intersect more performant than a scroll listener?
8Image lazy loading with x-intersect?
x-intersect.once="loaded = true" and :src="loaded ? realSrc : placeholder". The browser only loads the image when needed, measurably improving LCP.