How to create scroll animations with Tailwind CSS and Alpine.js

When building the newest version of my 11ty + tailwindcss + alpinejs boilerplate
I wanted to include an easy to use animate-on-scroll API. After researching
multiple possibilities it came down to using the excellent
AOS library and a custom solution with the
already included Tailwind CSS and AlpineJs.

Introducing AOS

Animate on Scroll provides an intersection observer combined with CSS
classes as well as some options to pass via attributes or on the javscript
initialization. The API is very simple and convenient to use:

<!-- This div will appear when it is in view -->
<div data-aos="fade-in">Hello World!</div>

<!-- There are multiple options that can be passed -->
<div data-aos="fade-in" data-aos-offset="200" data-aos-delay="50" data-aos-duration="1000">
  Hello World!
</div>

It's easy to use and works like a charm. However it introduces another library
into the build. The compressed size of AOS is only 7,21 KB in total, 4,98 KB
for the JS file and 2,23 KB for the CSS file. In my opinion, the size of the
library is negligible and not an argument against using it.

However, with Tailwind CSS and Alpine JS present in projects I wanted to explore
how to achieve a similar result with the tools at hand.

Recreating AOS functionality with Tailwind CSS and Alpine.js

Animate on Scroll triggers an animation once the element gets into the viewport.
To recreate this with Alpine.js we can use the
Intersect plugin. It offers a handy
way to create Intersection Observers and comes with a few options. Thanks to
Tailwind CSS we already have the necessary utility classes present to create a
simple fadeIn animation.

<!-- This div will appear when it is in view -->
<div
  x-data="{visible: false}"
  x-intersect="visible = true"
  :class="visible ? 'opacity-100' : 'opacity-0'"
  class="transition-opacity duration-300"
>
  Hello World!
</div>

There are a few things to unpack here. First, the x-data directive defines this
div as an AlpineJs component. Inside x-data we declare the variable visible
with a value of false. The x-intersect directive gets triggered once the div
is inside the viewport and changes visible from false to true. The x-bind
directive :class checks the boolean state of visible and applies opacity-0
if it's false and 'opacity-100' if it's true. Lastly we add a
transition-opacity and duration-300 class built into Tailwind CSS to create
a smooth transition between the two states.

Improving the MVP

While this solution works, it is not perfect. Right now x-intersect gets
triggered every time the element enters or leaves the viewport, but the value of
visible can only be changed to true once. Therefore we introduce some of the
options mentioned earlier and add the .once option to destroy the Intersection
Observer after the animation has been triggered. This is a significant
performance increase if you use a lot of these on a page.

To make things a little bit more usable in a real world scenario I would like to
add offset to the trigger. Many elements do not need to show up immediately once
they reach the viewport as the attention of the user is usually not at the very
bottom of the screen. Therefore we use another option provided by the intersect
plugin and add .margin.-20% (the order does not matter with Alpine). This
moves the trigger down the y-axis, 20% relative to the viewport height. Now the
element appears when it's a little bit closer to the center of the screen than
initially. It's important to note that the 20% are measured from the top of the
element's box.

<!-- This div will appear when it is in view -->
<div
  x-data="{visible: false}"
  x-intersect.once.margin.-80px="visible = true"
  :class="visible ? 'opacity-100' : 'opacity-0'"
  class="transition-opacity duration-300"
>
  Hello World!
</div>

Let's test what we have

Scrolling down, the page receives an overlay of
blue (75% of the available height)
and pink (25%) to help illustrate
exact location the 'Hello World' element gets triggered with the selected
offset.

I also replaced the 'opacity-0' with 'opacity-10' so the element is slightly
visible in this first test. On top of that I added some styling to give it a
visible bounding box (' bg-green-500 px-4 py-2 rounded ').

To give everything more breathing room I added margin-top and margin-bottom.
Without further ado, here is the slightly changed example. It will trigger the
fade in animation once the top of the box reaches the edge between blue and
pink.

Hello World!

Improve the animation

To polish things further I'd like to add a little bit of motion when fading in
the element. I want it to look like it's coming up from below, so we add a
translate-y-1/4 and a translate-y-0 to the :class directive. Doing this we
also need to change transition-opacity to transition-all to activate
transition for both. This gives us:

<!-- This div will appear when it is in view -->
<div
  x-data="{visible: false}"
  x-intersect.once.margin.-80px="visible = true"
  :class="visible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-1/4'"
  class="transition-all duration-300"
>
  Hello World!
</div>

Let's do another test, this time without the extra fuss:

Hello World!

Yes! It's working as expected ✨.

Conclusion

The DIY Animate on Scroll with Alpine JS and Tailwind CSS is not as simple and
straightforward as the AOS library. However, while you sacrifice some simplicity
you gain a great amount of possibilities. You are not limited to a fixed set of
animations provided and can - on the fly - use any css values for the animation.
Of course you could customize AOS, but that in turn would introduce complexity
again.

I am using Alpine JS and Tailwind CSS daily in nearly all of my projects. For me
it is straightforward to use the intersect plugin for scroll animation.

One more thing: Creating snippets for common use cases

I agree that adding so many elements to a DOM element to make it fade in is
tedious and not efficient. Therefore I have created VS Code snippets in true DRY
spirit to speed up the process. Below you find two of them that should translate
how to create them for any kind of utility classes. Feel free to grab them if
you want to experiment with this approach.

If you have never used VS Code Snippets before, fire up VS Code, press
CMD+Shift+P (Windows: Ctrl+Shift+P) and type Configure User Snippets. Select
New Global Snippets File..., choose a name and hit enter. Now you can paste
the snippets and save. Inside your html file you need to type ajs (the
prefix from the snippet) and select the snippet of your choice. If no
selection pops up, try ctrl + space. Otherwise have a look at the
(official VS Code docs
to learn how it works.

  "FadeIn | AlpineJs, Ajs Intersect plugin, Tailwind CSS": {
    "prefix": "ajs-fadeIn",
    "body": [
      "x-data=\"{visible: false}\""
      "x-intersect.once.margin.-25%=\"visible=true\""
      ":class=\"visible ? 'opacity-100' : 'opacity-0'\""
      "class=\"transition-all duration-300\""
    ]
  }
FadeIn
  "FadeInUp | AlpineJs, Ajs Intersect plugin, Tailwind CSS": {
    "prefix": "ajs-fadeInUp",
    "body": [
      "x-data=\"{visible: false}\""
      "x-intersect.once.margin.-25%=\"visible=true\""
      ":class=\"visible ? 'opacity-100 translate-0' : 'opacity-0 translate-y-1/4'\""
      "class=\"transition-all duration-300\""
    ]
  }
FadeInUp
  "FadeInUp | AlpineJs, Ajs Intersect plugin, Tailwind CSS": {
    "prefix": "ajs-fadeInDown",
    "body": [
      "x-data=\"{visible: false}\""
      "x-intersect.once.margin.-25%=\"visible=true\""
      ":class=\"visible ? 'opacity-100 translate-0' : 'opacity-0 -translate-y-1/4'\""
      "class=\"transition-all duration-300\""
    ]
  }
FadeInDown
  "FadeInRight | AlpineJs, Ajs Intersect plugin, Tailwind CSS": {
    "prefix": "ajs-fadeIn",
    "body": [
      "x-data=\"{visible: false}\""
      "x-intersect.once.margin.-25%=\"visible=true\""
      ":class=\"visible ? 'opacity-100 translate-0' : 'opacity-0 translate-x-1/4'\""
      "class=\"transition-all duration-300\""
    ]
  }
FadeInRight
 	"FadeInLeft | AlpineJs, Ajs Intersect plugin, Tailwind CSS": {
    "prefix": "ajs-fadeInRight",
    "body": [
			"x-data=\"{visible: false}\""
			"x-intersect.once.margin.-25%=\"visible=true\""
			":class=\"visible ? 'opacity-100 translate-0' : 'opacity-0 -translate-x-1/4'\""
			"class=\"transition-all duration-300\""
    ]
  }
FadeInLeft