ยท Updated on

Create a Stacking Cards Waterfall Effect with Tailwind and Alpine.js

Sneak peek of the stacking cards effect we're going to build

Lately, we’ve been seeing a lot more animations being used on landing pages. It’s not just about the product anymore; how you present it also shows off that you’re professional and pay attention to the little details.

When used in the right way, animation effects can really help engaging users. It’s all about finding the right balance: too many visuals can overwhelm people, but the right amount can guide them towards taking action. Our curated gallery of landing page designs showcases many examples of cool animations. One of our favorites is BlackWallet. We’ve taken inspiration to their landing to create a cool stacking cards effect that reveals as you scroll.

We’ll be using Tailwind CSS for styling, and for the animation we’ll be using Alpine.js and the Intersect Plugin.

Let’s get into the technical stuff!

Creating the HTML structure

To start off, let’s create the HTML structure. I won’t go into too much detail about this since it’s not the main focus of this tutorial. I’ll just give you the HTML code with all the necessary Tailwind utility classes for styling:

<div class="max-w-5xl mx-auto">
    <div class="relative z-0 space-y-14">
        <!-- Section #1 -->
        <section>
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-indigo-500 mb-2 relative inline-flex justify-center items-end">
                                Interesting
                                <svg class="absolute fill-indigo-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-01.png" width="519" height="490" alt="Illustration 01">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">01</div>
            </div>
        </section>
        <!-- Section #2 -->
        <section>
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-sky-500 mb-2 relative inline-flex justify-center items-end">
                                Engaging
                                <svg class="absolute fill-sky-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-02.png" width="519" height="490" alt="Illustration 02">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">02</div>
            </div>
        </section>
        <!-- Section #3 -->
        <section>
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-teal-500 mb-2 relative inline-flex justify-center items-end">
                                Appealing
                                <svg class="absolute fill-teal-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-03.png" width="519" height="490" alt="Illustration 03">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">03</div>
            </div>
        </section>
    </div>
</div>

The sctucture consists of a container with three sections, each containing an image and some text. The sections are set against a dark background with rounded borders, giving it a sleek look. The sections are stacked one below the other, with a vertical margin of 56px (space-y-14).

Designing the animation

Before we start using Alpine.js for animation, we must first decide how to arrange the cards when they are in “collapsed” mode. Instead of using absolute positioning – like in a previous tutorial on creating a sticky on scroll effect – we’ll use the CSS property transform: translateY() to shift the cards upwards and stack them on top of each other. This way, we can maintain the container’s height and easily determine when each section appears in the viewport using the Intersect Plugin.

So, let’s modify the HTML to stack the cards:

<div class="max-w-5xl mx-auto">
    <div class="relative z-0 space-y-14">
        <!-- Section #1 -->
        <section class="[--i:0]">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[2] -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #2 -->
        <section class="[--i:1]">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[1] -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #3 -->
        <section class="[--i:2]">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-0 -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
    </div>
</div>

In summary, here’s what we did:

  • Each section is given classes like [--i:0], [--i:1], and [--i:2]. These classes set a custom CSS variable --i, marking each section’s index. The index value will help us to determine the translate-y value for every section.
  • The direct descendants of each section are given the class -translate-y-[calc(100%*var(--i))], that makes them shift up based on their index. So, the first section’s content won’t move up at all (translate-y: -100% * 0 = 0), the second section’s content will move up by 100% (translate-y: -100% * 1 = -100%), and the third section’s content will move up by 200% (translate-y: -100% * 2 = -200%).
  • As subsequent sections stack over their predecessors, we inverted the stacking order using classes like z-[2], z-[1], and z-0.

This setup ensures our sections appear “collapsed”, preparing them for the cascading reveal upon scrolling.

Implementing the animation with Alpine.js and the Intersect plugin

So, here’s how the animation works: when you scroll down and a certain section is supposed to come into view, the content of that section will slide down to reveal itself, and it will also bring along all the following sections.

To do that, we’ll use Alpine.js and the Intersect Plugin – which is a wrapper for the Intersection Observer – to detect when an element appears in the viewport.

First, we need to make sure we load both required libraries. We’ll do that as quickly as possible, by including a script tag in the head of our document.

After ensuring both required libraries are loaded, our next step is to trigger an action when an element appears in the viewport using the x-intersect attribute. This, combined with other techniques, manages the correct translate-y value for each section.

<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/[email protected]/dist/cdn.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>

Note: If, by some wild chance, you’ve made it this far and are gearing up to say, “Hey, why use two whole libraries for such a teeny effect?” – please consider that the main purpose of this tutorial is educational. In the real world, you might already have Alpine.js installed and have reasons to use Intersect Plugin. But if the thought of adding another library for this makes you twitchy, consider this tutorial a starting point to craft your custom solution! ๐Ÿ˜‰

Now, let’s get back to it. To perform an action when an element enters the viewport, we need to add the x-intersect attribute to the element itself. In our case, we want to set a variable (which we’ll call entered) and assign it the index of the last section that entered the field of view. This will help us manage the correct value of translate-y to assign to each section:

<div class="max-w-5xl mx-auto" x-data="{ entered: '0' }">
    <div class="relative z-0 space-y-14">
        <!-- Section #1 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '0'" class="[--i:0]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[2] -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #2 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '1'" class="[--i:1]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[1] -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #3 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '2'" class="[--i:2]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-0 -translate-y-[calc(100%*var(--i))]">
                <!-- Card content -->
            </div>
        </section>
    </div>
</div>

Here’s a summary of what we did:

  • We added the x-data directive to the main container, which allows us to define the entered variable with a default value of 0.
  • Each section has been assigned the x-intersect directive. This will change the value of the entered variable as we scroll.
  • We also used the .margin modifier to control the rootMargin of the IntersectionObserver. By using the values -70%.0.-30%.0, we’re telling the IntersectionObserver to trigger the action when the section intersects an imaginary horizontal line positioned 30% from the bottom of the viewport.
  • Finally, we’ve added the :style attribute to define a new custom CSS variable --e. This variable helps us calculate the correct translate-y to assign to each section.

Now we have everything we need to properly translate the content of each section. Let’s complete the integration:

<div class="max-w-5xl mx-auto" x-data="{ entered: '0' }">
    <div class="relative z-0 space-y-14">
        <!-- Section #1 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '0'" class="[--i:0]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[2]" :class="entered >= 0 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #2 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '1'" class="[--i:1]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[1]" :class="entered >= 1 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <!-- Card content -->
            </div>
        </section>
        <!-- Section #3 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '2'" class="[--i:2]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-0" :class="entered >= 2 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <!-- Card content -->
            </div>
        </section>
    </div>
</div>

We have removed the class -translate-y-[calc(100%*var(--i))] because we need to handle the translate-y value dynamically based on the entered variable’s value.

To explain this further, let’s go through some examples. Let’s say section 1 is in view. In this case, the entered value will be 0. So, the translate-y value will be 0 for section 1, -100% for section 2, and -200% for section 3.

Now, if section 2 comes into view, the entered value becomes 1. As a result, the translate-y value will be 0 for both sections 1 and 2, and -100% for section 3.

Lastly, when section 3 is in view, the entered value will be 2. Therefore, the translate-y value for all three sections โ€” 1, 2, and 3 โ€” will be 0. In simpler terms, all the cards will be visible!

One important thing to note is that this animation occurs every time a section enters the viewport. If you want the animation to happen only once, you can use Alpine.js’s .once modifier.

Here is the final code:

<div class="max-w-5xl mx-auto">
    <div class="relative z-0 space-y-14" x-data="{ entered: '0' }">
        <!-- Section #1 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '0'" class="[--i:0]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[2]" :class="entered >= 0 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-indigo-500 mb-2 relative inline-flex justify-center items-end">
                                Interesting
                                <svg class="absolute fill-indigo-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-01.png" width="519" height="490" alt="Illustration 01">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">01</div>
            </div>
        </section>
        <!-- Section #2 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '1'" class="[--i:1]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-[1]" :class="entered >= 1 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-sky-500 mb-2 relative inline-flex justify-center items-end">
                                Engaging
                                <svg class="absolute fill-sky-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-02.png" width="519" height="490" alt="Illustration 02">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">02</div>
            </div>
        </section>
        <!-- Section #3 -->
        <section x-intersect.margin.-70%.0.-30%.0="entered = '2'" class="[--i:2]" :style="`--e:${entered}`">
            <div class="relative bg-slate-800 rounded-2xl border border-slate-700 overflow-hidden transition-transform duration-700 ease-in-out z-0" :class="entered >= 2 ? 'translate-y-0' : '-translate-y-[calc(100%*(var(--i)-var(--e)))]'">
                <div class="md:flex justify-between items-center">
                    <div class="shrink-0 px-12 py-14 max-md:pb-0 md:pr-0">
                        <div class="md:max-w-md">
                            <div class="font-nycd text-xl text-teal-500 mb-2 relative inline-flex justify-center items-end">
                                Appealing
                                <svg class="absolute fill-teal-500 opacity-40 -z-10" xmlns="http://www.w3.org/2000/svg" width="88" height="4" viewBox="0 0 88 4" aria-hidden="true" preserveAspectRatio="none">
                                    <path d="M87.343 2.344S60.996 3.662 44.027 3.937C27.057 4.177.686 3.655.686 3.655c-.913-.032-.907-1.923-.028-1.999 0 0 26.346-1.32 43.315-1.593 16.97-.24 43.342.282 43.342.282.904.184.913 1.86.028 1.999" />
                                </svg>
                            </div>
                            <h1 class="text-4xl font-extrabold text-slate-50 mb-4">The modern way to find high-quality devs</h1>
                            <p class="text-slate-400 mb-6">We're the world's largest marketplace of quality developers for early-stage startups. Need a hand with development? Grab one of ours!</p>
                            <a class="text-sm font-medium inline-flex items-center justify-center px-3 py-1.5 border border-slate-700 rounded-lg tracking-normal transition text-slate-300 hover:text-slate-50 group" href="#0">
                                Learn More <span class="text-slate-600 group-hover:translate-x-0.5 transition-transform duration-150 ease-in-out ml-1">-&gt;</span>
                            </a>
                        </div>
                    </div>
                    <img class="mx-auto max-md:-translate-x-[5%]" src="./illustration-03.png" width="519" height="490" alt="Illustration 03">
                </div>
                <div class="absolute left-12 bottom-0 h-14 flex items-center text-xs font-medium text-slate-400">03</div>
            </div>
        </section>
    </div>
</div>

Conclusions

You may have noticed that in our latest Tailwind tutorials, we have been pushing the boundaries a bit. Our goal is to provide more original content that can give you new ideas for your landing pages. If you’re enjoying these experiments and would like to see them included in our Tailwind Templates, let us know. We really love getting feedback from you guys in our community and we’re always working hard to make our products better and more suited to what you want!