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} />
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,
},
];
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;
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,
}),
});
👉 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;
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;
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;
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;
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";
}
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)