Reviews Carousel
Reviews Carousel is a React and Tailwind CSS media component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
media
What it is
Reviews Carousel is a React and Tailwind CSS media component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion lucide-react
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Reviews Carousel
"use client";
import { cn } from "@/lib/utils";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
const FRAME_OFFSET = -30;
const FRAMES_VISIBLE_LENGTH = 3;
function clamp(val: number, [min, max]: [number, number]): number {
return Math.min(Math.max(val, min), max);
}
export interface Review {
author: string;
body: string;
id: string | number;
title: string;
}
interface ReviewCardProps {
activeIndex: number;
index: number;
review: Review;
totalCards: number;
}
function ReviewCard({
review,
index,
activeIndex,
totalCards,
}: ReviewCardProps) {
const shouldReduceMotion = useReducedMotion();
const offsetIndex = index - activeIndex;
// Same logic as time-machine
const blur = activeIndex > index ? 2 : 0;
const opacity = activeIndex > index ? 0 : 1;
const scale = shouldReduceMotion
? 1
: clamp(1 - offsetIndex * 0.08, [0.08, 2]);
const y = shouldReduceMotion
? 0
: clamp(offsetIndex * FRAME_OFFSET, [
FRAME_OFFSET * FRAMES_VISIBLE_LENGTH,
Number.POSITIVE_INFINITY,
]);
const isActive = index === activeIndex;
return (
<motion.figure
animate={{
scale,
transition: {
damping: 20,
duration: 0.25,
mass: 0.5,
stiffness: 250,
type: "spring" as const,
},
y,
}}
className={cn(
"absolute left-1/2 w-[calc(100%-2rem)] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-foreground/10 bg-background/80 p-4 shadow-lg backdrop-blur-md sm:p-6"
)}
initial={false}
style={{
borderWidth: 1 / scale,
filter: `blur(${blur}px)`,
opacity,
pointerEvents: isActive ? "auto" : "none",
top: "50%", // Centrar verticalmente
transitionDuration: shouldReduceMotion ? "0ms" : "250ms",
transitionProperty: "opacity, filter",
transitionTimingFunction: "cubic-bezier(0.4, 0, 0.2, 1)",
willChange: "opacity, filter, transform",
zIndex: totalCards - index,
}}
>
<blockquote className="relative">
<div className="absolute -top-1 -left-2 text-4xl text-foreground/10 leading-none dark:text-foreground/5">
"
</div>
<p className="relative text-foreground/80 text-sm leading-relaxed">
{review.body}
</p>
</blockquote>
<figcaption className="mt-4 flex items-center gap-2 border-foreground/5 border-t pt-4">
<div className="flex flex-col">
<span className="font-semibold text-foreground text-xs">
{review.author}
</span>
<span className="text-foreground/50 text-xs">{review.title}</span>
</div>
</figcaption>
</motion.figure>
);
}
interface NavigationButtonProps {
direction: "prev" | "next";
disabled: boolean;
onClick: () => void;
}
function NavigationButton({
direction,
onClick,
disabled,
}: NavigationButtonProps) {
const Icon = direction === "prev" ? ChevronLeft : ChevronRight;
return (
<button
aria-label={direction === "prev" ? "Anterior" : "Siguiente"}
className={cn(
"box-gen group relative z-0 flex h-7 w-7 items-center justify-center rounded-full border-[0.5px] border-foreground/10 bg-background/50 backdrop-blur-sm transition-all duration-200",
disabled
? "cursor-not-allowed opacity-30"
: "cursor-pointer hover:border-foreground/20 hover:bg-background/70 hover:shadow-lg",
"dark:border-foreground/5 dark:bg-foreground/5 dark:hover:border-foreground/10 dark:hover:bg-foreground/10"
)}
disabled={disabled}
onClick={onClick}
type="button"
>
<Icon
className={cn(
"h-3.5 w-3.5 text-foreground/60 transition-colors",
"group-hover:text-foreground group-disabled:text-foreground/20"
)}
/>
</button>
);
}
export interface ReviewsCarouselProps {
autoPlay?: boolean;
autoPlayInterval?: number;
className?: string;
excludeIds?: (string | number)[];
height?: string;
reviews: Review[];
showIndicators?: boolean;
showNavigation?: boolean;
}
export default function ReviewsCarousel({
reviews,
className = "",
height = "300px",
excludeIds = [],
showIndicators = true,
showNavigation = true,
autoPlay = false,
autoPlayInterval = 5000,
}: ReviewsCarouselProps) {
// Filter out excluded reviews - use Set for O(1) lookups
const filteredReviews = useMemo(() => {
if (excludeIds.length === 0) {
return reviews;
}
const excludeSet = new Set(excludeIds);
const reviewsLength = reviews.length;
const results: typeof reviews = [];
// Use for loop for better performance
for (let i = 0; i < reviewsLength; i++) {
const review = reviews[i];
if (!excludeSet.has(review.id)) {
results.push(review);
}
}
return results;
}, [reviews, excludeIds]);
const maxIndex = filteredReviews.length - 1;
const [activeIndex, setActiveIndex] = useState(0);
// Auto-play functionality
useEffect(() => {
if (!autoPlay || maxIndex < 0) {
return;
}
const interval = setInterval(() => {
setActiveIndex((prevIndex) => {
if (prevIndex >= maxIndex) {
return 0;
}
return prevIndex + 1;
});
}, autoPlayInterval);
return () => {
clearInterval(interval);
};
}, [autoPlay, autoPlayInterval, maxIndex]);
// Keyboard navigation
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === "ArrowLeft") {
setActiveIndex((i) => clamp(i - 1, [0, maxIndex]));
} else if (event.key === "ArrowRight") {
setActiveIndex((i) => clamp(i + 1, [0, maxIndex]));
}
}
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [maxIndex]);
const goToPrevious = () => {
setActiveIndex((prevIndex) => {
if (prevIndex > 0) {
return prevIndex - 1;
}
return prevIndex;
});
};
const goToNext = () => {
setActiveIndex((prevIndex) => {
const newIndex = prevIndex + 1;
return newIndex <= maxIndex ? newIndex : prevIndex;
});
};
if (filteredReviews.length === 0) {
return null;
}
return (
<div
className={cn("relative mx-auto w-full max-w-4xl", className)}
style={{ height }}
>
{/* Stack of cards - using grid-stack pattern */}
<div className="relative h-full w-full py-8">
<div className="grid h-full w-full place-items-center">
{filteredReviews.map((review: Review, index: number) => (
<ReviewCard
activeIndex={activeIndex}
index={index}
key={review.id}
review={review}
totalCards={filteredReviews.length}
/>
))}
</div>
</div>
{/* Navigation buttons */}
{showNavigation || showIndicators ? (
<div className="absolute bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2">
{showNavigation ? (
<NavigationButton
direction="prev"
disabled={activeIndex <= 0}
onClick={goToPrevious}
/>
) : null}
{showIndicators ? (
<div className="flex items-center gap-2">
{filteredReviews.map((review: Review, index: number) => (
<button
aria-label={`Ir al testimonio ${index + 1}`}
className={cn(
"h-2 rounded-full transition-all duration-200",
index === activeIndex
? "w-8 bg-brand"
: "w-2 bg-brand/30 hover:bg-brand/50"
)}
key={review.id}
onClick={() => {
setActiveIndex(index);
}}
type="button"
/>
))}
</div>
) : null}
{showNavigation ? (
<NavigationButton
direction="next"
disabled={activeIndex === maxIndex}
onClick={goToNext}
/>
) : null}
</div>
) : null}
</div>
);
}