Use IntersectionObserver with Astro and Tailwind to create "reveal on scroll" animations
Tags: astro, tailwindcss, javascript, animation
Published: 2023-09-17 05:54:46

When working on [dailyboard.io](https://dailyboard.io) website, I was searching the web for an easy-to-use tutorial on how to use [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) with Astro. No luck. I didn't want to add a library to my bundle, so I just decided to figure it out by myself.

First, I wanted to put the code in the setup section of my Astro component. However, by seeing `IntersectionObserver is not defined` I quickly realised this code is executed on the server, where there's no `IntersectionObserver`.

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uo2y4nsgumee4qratpk2.png)

That's when I learned, you can actually use a regular `<script>` tag in an Astro component to run some client-side code.

So in my `Layout.astro` I wrote a simple script at the end of `<body>`:

```html
<body>
	<slot />
	<Footer />
	<script>
		const observer = new IntersectionObserver(
			(elements) => {
				elements.forEach((el) => {
					const animation = el.target.getAttribute('data-animate');

					if (animation && el.isIntersecting) {
						el.target.classList.add(animation);
						el.target.classList.remove('opacity-0');
					}
				});
			}
		);
		const elements = document.querySelectorAll('[data-animate]');
		elements.forEach((el) => {
			el.classList.add('opacity-0');
			observer.observe(el);
		});
	</script>
</body>
```

Then, I created some simple, custom animations in `tailwind.config.js`:
```javascript
module.exports = {
	content: ['./src/**/*.{astro,ts,vue}'],
	theme: {
		extend: {
			animation: {
				'fade-left': 'fadeLeft 800ms ease-in-out',
				'fade-right': 'fadeRight 800ms ease-in-out',
			},
			keyframes: {
				fadeLeft: {
					'0%': {
						transform: 'translate(-20%, 0)',
						opacity: 0,
					},
					'100%': {
						transform: 'translate(0,0)',
						opacity: 100,
					},
				},
				fadeRight: {
					'0%': {
						transform: 'translate(20%, 0)',
						opacity: 0,
					},
					'100%': {
						transform: 'translate(0,0)',
						opacity: 100,
					},
				},
			},
		},
	},
	variants: {
		extend: {},
	},
	plugins: [],
};
```

Now I was ready to stitch everything together! I just put `data-animate="animate-fade-left"` or `data-animate="animate-fade-right"` to any element I wanted to animate once it appeared in the viewport!

```html
<div class="..." data-animate="animate-fade-left">...</div>
<div class="..." data-animate="animate-fade-right">...</div>
```

It was a good start, but I decided to tweak the animations a bit. I decided to set a [threshold](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/thresholds) for the IntersectionObserver, so that it fired once at least 35% of an element was visible in the viewport.

```diff
<body>
	<slot />
	<Footer />
	<script>
		const observer = new IntersectionObserver(
			(elements) => {
				elements.forEach((el) => {
					const animation = el.target.getAttribute('data-animate');

					if (animation && el.isIntersecting) {
						el.target.classList.add(animation);
						el.target.classList.remove('opacity-0');
					}
				});
-			}
+			},
+			{ threshold: 0.35 }
		);
		const elements = document.querySelectorAll('[data-animate]');
		elements.forEach((el) => {
			el.classList.add('opacity-0');
			observer.observe(el);
		});
	</script>
</body>
```

The last thing I wanted to do, is to delay some animations to manage my viewers' focus and attention. I used `setTimeout` and a `data-delay` HTML attribute to control the animation right from the animation setup.


```html
<div class="..." data-animate="animate-fade-left">...</div>
<div class="..." data-animate="animate-fade-right" data-delay="300">...</div>
```

```diff
<body>
	<slot />
	<Footer />
	<script>
		const observer = new IntersectionObserver(
			(elements) => {
				elements.forEach((el) => {
					const animation = el.target.getAttribute('data-animate');
+					const delayData = el.target.getAttribute('data-delay');
+					const delay = delayData ? Number.parseInt(delayData) : 0;

					if (animation && el.isIntersecting) {
+						setTimeout(() => {
							el.target.classList.add(animation);
							el.target.classList.remove('opacity-0');
+						}, delay);						
					}
				});
		},
		{ threshold: 0.35 }
		);
		const elements = document.querySelectorAll('[data-animate]');
		elements.forEach((el) => {
			el.classList.add('opacity-0');
			observer.observe(el);
		});
	</script>
</body>
```

And that's it! With a few lines of code, I was able to achieve the desired result and ended up with a pretty versitile setup for controlling the animations.