Tutorials / / 5 min read
How to animate objects on scroll with Tailwind CSS: JavaScript and Alpine.js
Animate images as they scroll into view with Tailwind CSS and the Intersection Observer API, two ways: vanilla JavaScript or the Alpine.js Intersect plugin.
Today we are animating objects as they scroll into view, using the Intersection Observer API. We’ll build it twice, first with vanilla JavaScript, then with Alpine.js and its Intersect plugin, so you can pick whichever fits your stack.
What is the Intersection Observer API?
The Intersection Observer API lets you watch the visibility of elements on a page and run a callback when they enter or leave the viewport. Doing that by hand used to mean unreliable scroll math and sluggish browser performance; the observer handles it efficiently for you.
Use cases
- Scroll animations: fade, slide or rotate elements in as they become visible.
- Lazy loading: only load images or other content once the user is about to see them.
- Infinite scrolling: append more content as the reader reaches the bottom, no pagination needed.
- Ad and visibility tracking: measure when something was actually seen, for revenue or analytics.
The markup
id="rotateYImage": references the first image in the JavaScript code; this one rotates around the Y-axis.id="rotateXImage": references the second image; this one rotates around the X-axis.
<section>
<div>
<img
id="rotateYImage"
src="https://i.pinimg.com/564x/78/d1/c0/78d1c06554aead1dc1d1490f08d39ffd.jpg"
/>
</div>
</section>
<section>
<div>
<img
id="rotateXImage"
src="https://i.pinimg.com/564x/78/d1/c0/78d1c06554aead1dc1d1490f08d39ffd.jpg"
/>
</div>
</section>The script
The event listeners
const rotateYImage = document.getElementById("rotateYImage"): selects the element with the idrotateYImage.const rotateXImage = document.getElementById("rotateXImage"): selects the element with the idrotateXImage.
const rotateYImage = document.getElementById("rotateYImage");
const rotateXImage = document.getElementById("rotateXImage");The observer
const createObserver = (element, rotateProperty, targetDegree, step) => {: defines a function that takes the element, the rotate property, the target degree and the step.let degree = 0;: keeps track of the current rotation angle of the element.const observer = new IntersectionObserver((entries) => {: creates a new instance of the IntersectionObserver class.if (entry.isIntersecting) {: checks if the element is intersecting the viewport.const interval = setInterval(() => {: starts an interval that runs every 50 milliseconds.degree += step;: increments the current rotation angle by the step value until it reachestargetDegree.element.style.transform = ...: appliesperspective(1000px)plus the rotation to the element.clearInterval(interval);: stops the animation once the target angle is reached.
const createObserver = (element, rotateProperty, targetDegree, step) => {
let degree = 0;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const interval = setInterval(() => {
if (degree < targetDegree) {
degree += step;
element.style.transform = `perspective(1000px) ${rotateProperty}(${degree}deg)`;
} else {
clearInterval(interval);
}
}, 50);
}
});
});
observer.observe(element);
};Creating the observer
createObserver(rotateYImage, "rotateY", 360, 5);: spins the first image a full 360 degrees on the Y-axis, 5 degrees at a time.createObserver(rotateXImage, "rotateX", 30, 1);: tilts the second image 30 degrees on the X-axis, 1 degree at a time.
createObserver(rotateYImage, "rotateY", 360, 5);
createObserver(rotateXImage, "rotateX", 30, 1);The full script
document.addEventListener("DOMContentLoaded", function () {
const rotateYImage = document.getElementById("rotateYImage");
const rotateXImage = document.getElementById("rotateXImage");
const createObserver = (element, rotateProperty, targetDegree, step) => {
let degree = 0;
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const interval = setInterval(() => {
if (degree < targetDegree) {
degree += step;
element.style.transform = `perspective(1000px) ${rotateProperty}(${degree}deg)`;
} else {
clearInterval(interval);
}
}, 50);
}
});
});
observer.observe(element);
};
createObserver(rotateYImage, "rotateY", 360, 5);
createObserver(rotateXImage, "rotateX", 30, 1);
});The Alpine.js version
Same component, no separate script: the state lives in x-data, and the Intersect plugin, a convenient wrapper around the Intersection Observer API, triggers it when the element scrolls into view.
Alpine doesn’t ship the observer in its core, so you need the Intersect plugin first, either from a CDN or via npm.
Via CDN, just make sure to include it BEFORE Alpine’s core JS file:
<!-- Alpine Plugins -->
<script
defer
src="https://cdn.jsdelivr.net/npm/@alpinejs/intersect@3.x.x/dist/cdn.min.js"
></script>
<!-- Alpine Core -->
<script
defer
src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"
></script>Or install it from npm for use inside your bundle:
npm install @alpinejs/intersectThen initialize it from your bundle:
import Alpine from "alpinejs";
import intersect from "@alpinejs/intersect";
Alpine.plugin(intersect);Rotating the first image on visibility:
x-datainitializes the component with adegreeof rotation and atargetdegree; therotatefunction incrementsdegreeuntil it reachestarget, creating the rotation effect.x-intersect.once="rotate()": invokesrotatewhen the element is observed for the first time; the.oncemodifier makes sure it happens only once.- The
:stylebinding applies the rotation dynamically to theimgtag asdegreechanges.
<section>
<div>
<div
x-data="{ degree: 0, target: 360, rotate() { let interval = setInterval(() => { if (this.degree < this.target) { this.degree += 5; } else { clearInterval(interval); } }, 50); } }"
x-intersect.once="rotate()"
class="perspective-container ..."
>
<img
:style="`transform: perspective(1000px) rotateY(${degree}deg)`"
class="..."
src="https://i.pinimg.com/564x/78/d1/c0/78d1c06554aead1dc1d1490f08d39ffd.jpg"
/>
</div>
</div>
</section>Tilting the second image is the same idea with a twist, literally:
- Same
x-datasetup andx-intersectdirective, but this one tilts the image on the X-axis. - The
animatefunction gradually increases thedegreeof rotation to 30, one degree at a time.
<section class="overflow-hidden">
<div class="flex flex-col h-screen my-24">
<div
x-data="{ degree: 0, target: 30, animate() { let interval = setInterval(() => { if (this.degree < this.target) { this.degree += 1; } else { clearInterval(interval); } }, 50); } }"
x-intersect.once="animate()"
class="perspective-container"
>
<img
:style="`transform: perspective(1000px) rotateX(${degree}deg)`"
class="w-64 mx-auto transition-transform duration-1000 ease-in-out md:w-full rounded-3xl md:max-w-xl"
src="https://i.pinimg.com/564x/78/d1/c0/78d1c06554aead1dc1d1490f08d39ffd.jpg"
/>
</div>
</div>
</section>You can try the Alpine version separately: live demo and source code.
Which one should you use?
If Alpine is already on the page, the Intersect plugin keeps everything in the markup, at the cost of one extra plugin to install. If not, the vanilla observer does the same job with no dependencies at all.
Conclusion
Two images, one observer, and a few degrees per tick: that’s scroll-triggered animation in whichever flavor your project prefers.
Hope you enjoyed this tutorial and have a great day!
/Michael Andreuzza