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.
html
<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 id rotateYImage.
  • const rotateXImage = document.getElementById("rotateXImage"): selects the element with the id rotateXImage.
js
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 reaches targetDegree.
  • element.style.transform = ...: applies perspective(1000px) plus the rotation to the element.
  • clearInterval(interval);: stops the animation once the target angle is reached.
js
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.
js
createObserver(rotateYImage, "rotateY", 360, 5);
createObserver(rotateXImage, "rotateX", 30, 1);

The full script

js
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:

html
<!-- 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:

bash
npm install @alpinejs/intersect

Then initialize it from your bundle:

js
import Alpine from "alpinejs";
import intersect from "@alpinejs/intersect";

Alpine.plugin(intersect);

Rotating the first image on visibility:

  • x-data initializes the component with a degree of rotation and a target degree; the rotate function increments degree until it reaches target, creating the rotation effect.
  • x-intersect.once="rotate()": invokes rotate when the element is observed for the first time; the .once modifier makes sure it happens only once.
  • The :style binding applies the rotation dynamically to the img tag as degree changes.
html
<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-data setup and x-intersect directive, but this one tilts the image on the X-axis.
  • The animate function gradually increases the degree of rotation to 30, one degree at a time.
html
<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