DEV Community

Cover image for I Built a Testimonial Carousel That Actually Feels Alive (Next.js + Tailwind CSS)
Rohitash Singh
Rohitash Singh

Posted on

I Built a Testimonial Carousel That Actually Feels Alive (Next.js + Tailwind CSS)

When I was building my landing page, I didn’t want a boring slider.
I wanted something that moves smoothly, feels real, and works on every device.

This blog is about that exact carousel.
No fake demo. No shortcut code.
This is production-ready, fully working, and used in a real project.

What This Carousel Does (In Simple Words)

  • Shows real testimonials
  • Works on mobile, tablet, desktop
  • Supports swipe (touch + mouse)
  • Smooth Framer Motion animation
  • Built using Next.js + React + Tailwind CSS

And most important —
👉 Every line of code below is actually used

How I Use the Component

<SectionSliderTestimonials testimonials={TESTIMONIALS} />
Enter fullscreen mode Exit fullscreen mode

Testimonial Data

const TESTIMONIALS = [
  {
    id: "1",
    name: "Aman Sharma",
    role: "Software Engineer",
    quote:
      "I rebuilt my resume in 10 minutes and started getting interview calls within a week.",
    rating: 5,
  },
  {
    id: "2",
    name: "Neha Verma",
    role: "Fresher",
    quote:
      "The ATS-friendly templates made a huge difference. Much better than Word or Canva.",
    rating: 5,
  },
  {
    id: "3",
    name: "Rohit Singh",
    role: "Product Manager",
    quote:
      "Clean design, easy editing, and perfect formatting. Exactly what recruiters expect.",
    rating: 4,
  },
  {
    id: "4",
    name: "Priyanka Rawat",
    role: "Product Manager",
    quote:
      "Clean design, easy editing, and perfect formatting. Exactly what recruiters expect.",
    rating: 4,
  },
];
Enter fullscreen mode Exit fullscreen mode

Main Slider Component (Heart of the Carousel)

"use client";

import React, { FC, useEffect, useState } from "react";
import Heading from "../shared/Heading";
import { AnimatePresence, motion, MotionConfig } from "framer-motion";
import { useSwipeable } from "react-swipeable";
import PrevBtn from "../shared/PrevBtn";
import NextBtn from "../shared/NextBtn";
import { useWindowSize } from "react-use";
import { variants } from "@/utils/animationVariants";
import { TestimonialType } from "@/types/slider";
import TestimonialCard from "../CardsCategory/TestimonialCard";

interface Props {
  heading?: string;
  subHeading?: string;
  testimonials: TestimonialType[];
  itemPerRow?: 1 | 2 | 3;
}

const SectionSliderTestimonials: FC<Props> = ({
  heading = "Loved by job seekers",
  subHeading = "Real stories from professionals who landed interviews",
  testimonials,
  itemPerRow = 3,
}) => {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [direction, setDirection] = useState(0);
  const [items, setItems] = useState(1);

  const width = useWindowSize().width;

  useEffect(() => {
    if (width < 640) return setItems(1);
    if (width < 1024) return setItems(2);
    setItems(itemPerRow);
  }, [width, itemPerRow]);

  const changeIndex = (val: number) => {
    setDirection(val > currentIndex ? 1 : -1);
    setCurrentIndex(val);
  };

  const handlers = useSwipeable({
    onSwipedLeft: () =>
      currentIndex < testimonials.length - 1 &&
      changeIndex(currentIndex + 1),
    onSwipedRight: () =>
      currentIndex > 0 && changeIndex(currentIndex - 1),
    trackMouse: true,
  });

  if (!items) return null;

  return (
    <section className="relative py-24 container mx-auto px-4 py-10 sm:px-0">
      <Heading desc={subHeading} isCenter>
        {heading}
      </Heading>

      <MotionConfig
        transition={{
          x: { type: "spring", stiffness: 300, damping: 30 },
          opacity: { duration: 0.2 },
        }}
      >
        <div className="relative mt-14" {...handlers}>
          <div className="overflow-hidden">
            <motion.ul className="whitespace-nowrap -mx-2 xl:-mx-4">
              <AnimatePresence initial={false} custom={direction}>
                {testimonials.map((item, index) => (
                  <motion.li
                    key={item.id}
                    className="inline-block px-2 xl:px-4"
                    custom={direction}
                    initial={{ x: `${(currentIndex - 1) * -100}%` }}
                    animate={{ x: `${currentIndex * -100}%` }}
                    variants={variants(200, 1)}
                    style={{
                      width: `calc(100% / ${items})`,
                    }}
                  >
                    <TestimonialCard testimonial={item} />
                  </motion.li>
                ))}
              </AnimatePresence>
            </motion.ul>
          </div>

          {currentIndex > 0 && (
            <PrevBtn
              onClick={() => changeIndex(currentIndex - 1)}
              className="w-9 h-9 xl:w-12 xl:h-12 absolute -left-3 xl:-left-6 top-1/2 -translate-y-1/2"
            />
          )}

          {testimonials.length > currentIndex + items && (
            <NextBtn
              onClick={() => changeIndex(currentIndex + 1)}
              className="w-9 h-9 xl:w-12 xl:h-12 absolute -right-3 xl:-right-6 top-1/2 -translate-y-1/2"
            />
          )}
        </div>
      </MotionConfig>
    </section>
  );
};

export default SectionSliderTestimonials;
Enter fullscreen mode Exit fullscreen mode

Animation Logic (Simple but Powerful) [animationVariants.ts]

export const variants = (x = 1000, opacity = 0) => ({
  enter: (direction: number) => ({
    x: direction > 0 ? x : -x,
    opacity,
  }),
  center: {
    x: 0,
    opacity: 1,
  },
  exit: (direction: number) => ({
    x: direction < 0 ? x : -x,
    opacity,
  }),
});
Enter fullscreen mode Exit fullscreen mode

👉 Slide feels natural, not robotic.

Testimonial Card (Clean & Honest UI)

import { FC } from "react";
import { Star } from "lucide-react";
import { TestimonialType } from "@/types/slider";

interface Props {
  testimonial: TestimonialType;
}

const TestimonialCard: FC<Props> = ({ testimonial }) => {
  return (
    <div className="flex h-full flex-col justify-between rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm dark:bg-white/5">
      <div>
        <div className="flex gap-1">
          {Array.from({ length: testimonial.rating }).map((_, i) => (
            <Star
              key={i}
              className="h-4 w-4 fill-yellow-400 text-yellow-400"
            />
          ))}
        </div>

        <p className="mt-4 text-sm text-zinc-700 dark:text-white/80">{testimonial.quote}</p>
      </div>

      <div className="mt-6 border-t pt-4">
        <p className="text-sm font-medium">{testimonial.name}</p>
        <p className="text-xs text-zinc-500">{testimonial.role}</p>
      </div>
    </div>
  );
};

export default TestimonialCard;
Enter fullscreen mode Exit fullscreen mode

Heading Section (Clean & Honest UI)

import React, { HTMLAttributes, ReactNode } from "react";

export interface HeadingProps extends HTMLAttributes<HTMLHeadingElement> {
  fontClass?: string;
  desc?: ReactNode;
  isCenter?: boolean;
}

const Heading: React.FC<HeadingProps> = ({
  children,
  desc = "Discover the most outstanding articles in all topics of life. ",
  className = "mb-10 text-neutral-900 dark:text-neutral-50",
  isCenter = false,
  ...args
}) => {
  return (
    <div className={`nc-Section-Heading relative ${className}`}>
      <div
        className={
          isCenter ? "text-center w-full max-w-2xl mx-auto mb-4" : "max-w-2xl"
        }
      >
        <h2 className={`text-3xl md:text-4xl font-semibold`} {...args}>
          {children || `Section Heading`}
        </h2>
        {desc && (
          <span className="block mt-2 md:mt-3 font-normal text-base sm:text-lg text-neutral-500 dark:text-neutral-400">
            {desc}
          </span>
        )}
      </div>
    </div>
  );
};

export default Heading;
Enter fullscreen mode Exit fullscreen mode

Previous Button

import twFocusClass from "@/utils/twFocusClass";
import React, { ButtonHTMLAttributes, FC } from "react";
import { ChevronLeft } from "lucide-react";

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {}

const PrevBtn: FC<Props> = ({ className = "w-10 h-10", disabled, ...args }) => {
  return (
    <button
      disabled={disabled}
      className={`PrevBtn ${className}
        bg-white dark:bg-neutral-900
        border border-neutral-200 dark:border-neutral-700
        rounded-full inline-flex items-center justify-center
        text-neutral-700 dark:text-neutral-200
        hover:bg-neutral-50 dark:hover:bg-neutral-800
        hover:text-neutral-900 dark:hover:text-white
        disabled:opacity-40 disabled:cursor-not-allowed
        transition-colors
        ${twFocusClass()}
      `}
      {...args}
    >
      <ChevronLeft className="w-5 h-5" />
    </button>
  );
};

export default PrevBtn;
Enter fullscreen mode Exit fullscreen mode

Next Button

import twFocusClass from "@/utils/twFocusClass";
import React, { ButtonHTMLAttributes, FC } from "react";
import { ChevronLeft } from "lucide-react";

interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {}

const PrevBtn: FC<Props> = ({ className = "w-10 h-10", disabled, ...args }) => {
  return (
    <button
      disabled={disabled}
      className={`PrevBtn ${className}
        bg-white dark:bg-neutral-900
        border border-neutral-200 dark:border-neutral-700
        rounded-full inline-flex items-center justify-center
        text-neutral-700 dark:text-neutral-200
        hover:bg-neutral-50 dark:hover:bg-neutral-800
        hover:text-neutral-900 dark:hover:text-white
        disabled:opacity-40 disabled:cursor-not-allowed
        transition-colors
        ${twFocusClass()}
      `}
      {...args}
    >
      <ChevronLeft className="w-5 h-5" />
    </button>
  );
};

export default PrevBtn;
Enter fullscreen mode Exit fullscreen mode

Utils (twFocusClass)

export default function twFocusClass(hasRing = false) {
  if (!hasRing) {
    return "focus:outline-none";
  }
  return "focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-6000 dark:focus:ring-offset-0";
}
Enter fullscreen mode Exit fullscreen mode

Final Thoughts (From One Developer to Another)

I didn’t build this carousel to show off animation.
I built it so users can feel trust when they read testimonials.

If you’re building:

  • a SaaS landing page
  • a portfolio
  • or a product website

This testimonial carousel is part of a separate SaaS application I’m working on.

I’m also building ZenithX (zenithx.in), where I experiment with the same principles — clean UI, smooth motion, and performance-first components.

Everything shared here comes from real usage, not theory.

This carousel just works — no hacks, no magic.

Top comments (0)