✨ Welcome Summer Sale! Get all templates for $79 (40% Off) ✨

How to Build a Fancy Testimonial Slider with Tailwind CSS and Alpine.js

Fancy testimonial slider preview

Having testimonials on your landing page or website can be an excellent way to gain credibility and trust from potential customers.

So, in this tutorial we will walk you through creating a fancy testimonial slider with Tailwind CSS, and similar to the previous tutorials, we will divide the guide into three parts: using Alpine.js, Next.js and Vue.

We’ve created testimonial components for several of our Tailwind CSS templates, but if you want to see in action the exact same slider component that we’ll be creating in this tutorial, we recommend taking a look at the Dark Next.js landing page template called Stellar.

To begin, we’ll create the component in pure HTML, utilizing the powerful combination of Tailwind CSS and the Alpine.js library. As usual, we’ll begin by setting up a basic HTML structure to house our component:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8">
    <title>Fancy Testimonials Slider</title>
    <meta name="viewport" content="width=device-width,initial-scale=1">
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&display=swap" rel="stylesheet">
    <script src="https://cdn.tailwindcss.com"></script>
    <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
    <script>
        tailwind.config = {
            theme: {
                extend: {
                    fontFamily: {
                        inter: ['Inter', 'sans-serif'],
                    },
                },
            },
        };
    </script>
</head>

<body class="relative font-inter antialiased">

    <main class="relative min-h-screen flex flex-col justify-center bg-slate-50 overflow-hidden">
        <div class="w-full max-w-6xl mx-auto px-4 md:px-6 py-24">
            <div class="flex justify-center">

                <!-- Fancy testimonial slider -->

            </div>
        </div>
    </main>

</body>

</html>

We’ll start by designing a single testimonial, and then we’ll use the x-for directive to iterate through all the testimonials defined ina a JavaScript array.

Creating the HTML structure of the testimonial with Tailwind CSS

Let’s set up HTML structure for our testimonial. The content will consist of three parts:

  • Testimonial image
  • Testimonial text
  • Buttons for navigating between testimonials
<div class="w-full max-w-3xl mx-auto text-center">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
            <div class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
                <div class="absolute inset-0 -z-10">
                    <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" src="./testimonial-01.jpg" width="56" height="56" alt="Testimonial 01">
                </div>
            </div>
        </div>
    </div>
    <!-- Text -->
    <div class="mb-9">
        <div class="relative flex flex-col transition-all duration-150 delay-300 ease-in-out">
            <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']">The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.</div>
        </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
        <button class="inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 bg-indigo-500 text-white shadow-indigo-950/10">
            <span>Jessie J</span> <span class="text-indigo-200">-</span> <span>Acme LTD</span>
        </button>
        <button
            class="inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 bg-white hover:bg-indigo-100 text-slate-900">
            <span>Nick V</span> <span class="text-slate-300">-</span> <span>Malika Inc.</span>
        </button>  
        <button class="inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150 bg-white hover:bg-indigo-100 text-slate-900">
            <span>Amelia W</span> <span class="text-slate-300">-</span> <span>Panda AI</span>
        </button>
    </div>
</div>

Above the text, we have created a circle with a diameter of 480px, and applied a gradient to achieve a shading effect.

Next, we have positioned the testimonial image within a container element with a height of 128px (h-32). To ensure that the testimonial avatar transitions along the circular shape without overlapping the underlying text, we have applied a gradient mask to the container element.

To create the mask, we have leveraged Tailwind CSS’s arbitrary properties to define a custom class called [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]. This class generates the CSS property mask-image: linear-gradient(0deg,transparent,#fff 20%,#fff);.

Great! Now let’s move on to the next part, where we will use Alpine.js’s x-for directive to generate multiple testimonials dynamically, and use some Tailwind CSS classes to create transitions between testimonials.

Creating an x-for loop to generate the testimonials

As you may know, everything in Alpine starts with the x-data directive. While in previous components it was sufficient to define a variable with an initial value, in this case, we need something more complex.

To maintain clean and organized code, we’ll use the global Alpine.data(...) and create a JavaScript object that contains all the necessary information to render our testimonials slider:

<div class="w-full max-w-3xl mx-auto text-center" x-data="slider">
    ...
</div>
<!-- Slider data and functionality: https://github.com/alpinejs/alpine -->
<script>
document.addEventListener('alpine:init', () => {
    Alpine.data('slider', () => ({
        active: 0,
        autorotate: true,
        autorotateTiming: 7000,
        testimonials: [
            {
                img: './testimonial-01.jpg',
                quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
                name: 'Jessie J',
                role: 'Acme LTD'
            },
            {
                img: './testimonial-02.jpg',
                quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
                name: 'Nick V',
                role: 'Malika Inc.'
            },
            {
                img: './testimonial-03.jpg',
                quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
                name: 'Amelia W',
                role: 'Panda AI'
            },
        ],
        init() {
            if (this.autorotate) {
                this.autorotateInterval = setInterval(() => {
                    this.active = this.active + 1 === this.testimonials.length ? 0 : this.active + 1
                }, this.autorotateTiming)
            }
        },
    }))
})
</script>

In the above code, we have defined a component object called slider that contains the properties required by our slider, which are:

  • active is an index that indicates which testimonial is currently displayed.
  • autorotate is a boolean that indicates whether the slider should rotate automatically or not.
  • autorotateTiming is a number that indicates the time interval (in milliseconds) at which the slider should rotate automatically.
  • testimonials is an array of objects that contains all the information needed to generate the various testimonials.

In addition, we have defined an init() method that runs when the component is initialized. This method checks if the slider should rotate automatically and, if so, sets an interval that changes the active index every autorotateTiming milliseconds.

With the testimonials array now defined, we can use the x-for directive to iterate through the array and render the necessary elements for each testimonial. This allows us to dynamically generate the testimonials on the page. Let’s proceed with the implementation of the x-for directive:

<div class="w-full max-w-3xl mx-auto text-center" x-data="slider">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
            <div class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
                <!-- Alpine.js template for testimonial images: https://github.com/alpinejs/alpine#x-for -->
                <template x-for="(testimonial, index) in testimonials" :key="index">
                    <div
                        x-show="active === index"
                        class="absolute inset-0 -z-10"
                    >
                        <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name">
                    </div>
                </template>
            </div>
        </div>
    </div>
    <!-- Text -->
    <div class="mb-9">
        <div class="relative flex flex-col transition-all duration-150 delay-300 ease-in-out">
            <!-- Alpine.js template for testimonials: https://github.com/alpinejs/alpine#x-for -->
            <template x-for="(testimonial, index) in testimonials" :key="index">
                <div
                    x-show="active === index"
                >
                    <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']" x-text="testimonial.quote"></div>
                </div>
            </template>
        </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
        <!-- Alpine.js template for buttons: https://github.com/alpinejs/alpine#x-for -->
        <template x-for="(testimonial, index) in testimonials" :key="index">
            <button
                class="inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150"
                :class="active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'"
                @click="active = index;"
            >
                <span x-text="testimonial.name"></span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span x-text="testimonial.role"></span>
            </button>
        </template>
    </div>
</div>

We have effectively utilized the x-for directive in three instances:

  • To generate the images for each testimonial.
  • To generate to dynamically generate the text content for each testimonial.
  • To generate the navigation buttons.

By adopting this approach, we will be able to add or remove a testimonial from the array without having to change anything in the HTML. This flexibility provides an efficient way to manage and update our code effortlessly.

Adding transitions between testimonials

To make the slider more appealing, let’s add transitions between the testimonials. To do this, we will use the x-transition directive in conjunction with Tailwind CSS classes.

Firstly, we will add a 60-degree rotation effect to the testimonial avatar, with a custom easing effect:

<div
    x-show="active === index"
    class="absolute inset-0 -z-10"
    x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 order-first"
    x-transition:enter-start="opacity-0 -rotate-[60deg]"
    x-transition:enter-end="opacity-100 rotate-0"
    x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700"
    x-transition:leave-start="opacity-100 rotate-0"
    x-transition:leave-end="opacity-0 rotate-[60deg]"
>
    <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name">
</div>

For the testimonial text, we will add a fading in/out effect from left to right:

<div
    x-show="active === index"
    x-transition:enter="transition ease-in-out duration-500 delay-200 order-first"
    x-transition:enter-start="opacity-0 -translate-x-4"
    x-transition:enter-end="opacity-100 translate-x-0"
    x-transition:leave="transition ease-out duration-300 delay-300 absolute"
    x-transition:leave-start="opacity-100 translate-x-0"
    x-transition:leave-end="opacity-0 translate-x-4"
>
    <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']" x-text="testimonial.quote"></div>
</div>

Final Touches

We’re nearly done with the testimonial component, but there are a few final touches we can add to make it even better.

Disabling autorotation on user interaction

One important enhancement is to disable automatic rotation when the user interacts with the slider. We can achieve this by adding a stopAutorotate() method that clears the automatic rotation interval. Let’s implement it:

stopAutorotate() {
    clearInterval(this.autorotateInterval)
    this.autorotateInterval = null
}

And we call this method when the user interacts with the slider:

<button
    class="inline-flex ...'"
    @click="active = index; stopAutorotate();"
>
    <span x-text="testimonial.name"></span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span x-text="testimonial.role"></span>
</button>

This way, the slider stops rotating automatically when the user clicks on one of the buttons. But there’s one last integration to be done.

Using CSS transitions between testimonials with different height

Currently, the text of each testimonial consists of a similar number of characters, which results in all the testimonials having the same height.

However, if one testimonial has significantly more text than the others, the height of the slider abruptly changes, producing an undesirable effect.

To mitigate this issue, we have already added the classes transition-all duration-150 delay-300 ease-in-out to the testimonial text wrapper. However, we also need to explicitly define the height of the testimonial text wrapper for the transition to take place.

For this, we’ll add a heightFix() method that dynamically sets the height of the testimonial text wrapper based on the height of the current testimonial text (which will be referenced by a x-ref="testimonials" attribute):

heightFix() {
    this.$nextTick(() => {
        this.$refs.testimonials.style.height = this.$refs.testimonials.children[this.active + 1].offsetHeight + 'px'
    })
}

Then, we’ll invoke it both in the init() method and within the $watch magic property, like this:

init() {
    if (this.autorotate) {
        this.autorotateInterval = setInterval(() => {
            this.active = this.active + 1 === this.testimonials.length ? 0 : this.active + 1
        }, this.autorotateTiming)
    }
    this.$watch('active', callback => this.heightFix())
}

Essentially, we are telling Alpine to call the heightFix() method on page load, and every time the active property changes. Cool, isn’t it?

Conclusions

Our testimonial component is complete! We can use it on any page of our website by simply copying the complete code, and defining the testimonials array. Here’s the complete code:

<!-- Fancy testimonial slider component -->
<div class="w-full max-w-3xl mx-auto text-center" x-data="slider">
    <!-- Testimonial image -->
    <div class="relative h-32">
        <div class="absolute top-0 left-1/2 -translate-x-1/2 w-[480px] h-[480px] pointer-events-none before:absolute before:inset-0 before:bg-gradient-to-b before:from-indigo-500/25 before:via-indigo-500/5 before:via-25% before:to-indigo-500/0 before:to-75% before:rounded-full before:-z-10">
            <div class="h-32 [mask-image:_linear-gradient(0deg,transparent,theme(colors.white)_20%,theme(colors.white))]">
                <!-- Alpine.js template for testimonial images: https://github.com/alpinejs/alpine#x-for -->
                <template x-for="(testimonial, index) in testimonials" :key="index">
                    <div
                        x-show="active === index"
                        class="absolute inset-0 -z-10"
                        x-transition:enter="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700 order-first"
                        x-transition:enter-start="opacity-0 -rotate-[60deg]"
                        x-transition:enter-end="opacity-100 rotate-0"
                        x-transition:leave="transition ease-[cubic-bezier(0.68,-0.3,0.32,1)] duration-700"
                        x-transition:leave-start="opacity-100 rotate-0"
                        x-transition:leave-end="opacity-0 rotate-[60deg]"
                    >
                        <img class="relative top-11 left-1/2 -translate-x-1/2 rounded-full" :src="testimonial.img" width="56" height="56" :alt="testimonial.name">
                    </div>
                </template>
            </div>
        </div>
    </div>
    <!-- Text -->
    <div class="mb-9">
        <div class="relative flex flex-col transition-all duration-150 delay-300 ease-in-out" x-ref="testimonials">
            <!-- Alpine.js template for testimonials: https://github.com/alpinejs/alpine#x-for -->
            <template x-for="(testimonial, index) in testimonials" :key="index">
                <div
                    x-show="active === index"
                    x-transition:enter="transition ease-in-out duration-500 delay-200 order-first"
                    x-transition:enter-start="opacity-0 -translate-x-4"
                    x-transition:enter-end="opacity-100 translate-x-0"
                    x-transition:leave="transition ease-out duration-300 delay-300 absolute"
                    x-transition:leave-start="opacity-100 translate-x-0"
                    x-transition:leave-end="opacity-0 translate-x-4"
                >
                    <div class="text-2xl font-bold text-slate-900 before:content-['\201C'] after:content-['\201D']" x-text="testimonial.quote"></div>
                </div>
            </template>
        </div>
    </div>
    <!-- Buttons -->
    <div class="flex flex-wrap justify-center -m-1.5">
        <!-- Alpine.js template for buttons: https://github.com/alpinejs/alpine#x-for -->
        <template x-for="(testimonial, index) in testimonials" :key="index">
            <button
                class="inline-flex justify-center whitespace-nowrap rounded-full px-3 py-1.5 m-1.5 text-xs shadow-sm focus-visible:outline-none focus-visible:ring focus-visible:ring-indigo-300 dark:focus-visible:ring-slate-600 transition-colors duration-150"
                :class="active === index ? 'bg-indigo-500 text-white shadow-indigo-950/10' : 'bg-white hover:bg-indigo-100 text-slate-900'"
                @click="active = index; stopAutorotate();"
            >
                <span x-text="testimonial.name"></span> <span :class="active === index ? 'text-indigo-200' : 'text-slate-300'">-</span> <span x-text="testimonial.role"></span>
            </button>
        </template>
    </div>
</div>
<!-- Slider data and functionality: https://github.com/alpinejs/alpine -->
<script>
    document.addEventListener('alpine:init', () => {
        Alpine.data('slider', () => ({
            active: 0,
            autorotate: true,
            autorotateTiming: 7000,
            testimonials: [
                {
                    img: './testimonial-01.jpg',
                    quote: "The ability to capture responses is a game-changer. If a user gets tired of the sign up and leaves, that data is still persisted. Additionally, it's great to select between formats.",
                    name: 'Jessie J',
                    role: 'Acme LTD'
                },
                {
                    img: './testimonial-02.jpg',
                    quote: "Having the power to capture user feedback is revolutionary. Even if a participant abandons the sign-up process midway, their valuable input remains intact.",
                    name: 'Nick V',
                    role: 'Malika Inc.'
                },
                {
                    img: './testimonial-03.jpg',
                    quote: "The functionality to capture responses is a true game-changer. Even if a user becomes fatigued during sign-up and abandons the process, their information remains stored.",
                    name: 'Amelia W',
                    role: 'Panda AI'
                },
            ],
            init() {
                if (this.autorotate) {
                    this.autorotateInterval = setInterval(() => {
                        this.active = this.active + 1 === this.testimonials.length ? 0 : this.active + 1
                    }, this.autorotateTiming)
                }
                this.$watch('active', callback => this.heightFix())
            },
            stopAutorotate() {
                clearInterval(this.autorotateInterval)
                this.autorotateInterval = null
            },
            heightFix() {
                this.$nextTick(() => {
                    this.$refs.testimonials.style.height = this.$refs.testimonials.children[this.active + 1].offsetHeight + 'px'
                })
            }
        }))
    })
</script>
<!-- End: Fancy testimonial slider component -->

Now that we have shown you how simple it is to implement a fancy testimonial slider on your website and landing page, all you have to do is start hunting for testimonials to showcase how much your current customers value your product. We are aware that this is not an easy task, but with a little patience and time, you can collect plenty of wonderful testimonials that will delight your visitors and prospects.