reviews /
Google Review Carousel
Full-featured Google reviews carousel. Desktop shows 3 cards, mobile single card. Auto-advances every 5s, pauses on interact. Click any card to open full review in a modal.
Preview
Source
tsx
"use client";
import { useState, useCallback, useRef, useEffect } from "react";
import { Star, ChevronLeft, ChevronRight, X } from "lucide-react";
import { googleReviews } from "@/lib/placeholders";
// ─── Google SVG Assets ────────────────────────────────────────────────────────
function GoogleLogo({ className = "h-5 w-5" }: { className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" className={className}>
<path fill="#4285F4" d="M24 9.5c3.54 0 6.71 1.22 9.21 3.6l6.85-6.85C35.9 2.38 30.47 0 24 0 14.62 0 6.51 5.38 2.56 13.22l7.98 6.19C12.43 13.72 17.74 9.5 24 9.5z"/>
<path fill="#34A853" d="M46.98 24.55c0-1.57-.15-3.09-.38-4.55H24v9.02h12.94c-.58 2.96-2.26 5.48-4.78 7.18l7.73 6c4.51-4.18 7.09-10.36 7.09-17.65z"/>
<path fill="#FBBC05" d="M10.53 28.59A14.5 14.5 0 0 1 9.5 24c0-1.59.28-3.14.76-4.59l-7.98-6.19A23.99 23.99 0 0 0 0 24c0 3.77.9 7.34 2.56 10.5l7.97-5.91z"/>
<path fill="#EA4335" d="M24 48c6.48 0 11.93-2.13 15.89-5.81l-7.73-6c-2.15 1.45-4.92 2.3-8.16 2.3-6.26 0-11.57-4.22-13.47-9.91l-7.98 5.91C6.51 42.62 14.62 48 24 48z"/>
</svg>
);
}
function VerifiedBadge() {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 14 14" className="h-3.5 w-3.5 flex-shrink-0">
<path fill="#197BFF" d="M6.757.236a.35.35 0 0 1 .486 0l1.106 1.07a.35.35 0 0 0 .329.089l1.493-.375a.35.35 0 0 1 .422.244l.422 1.48a.35.35 0 0 0 .24.24l1.481.423a.35.35 0 0 1 .244.422l-.375 1.493a.35.35 0 0 0 .088.329l1.071 1.106a.35.35 0 0 1 0 .486l-1.07 1.106a.35.35 0 0 0-.089.329l.375 1.493a.35.35 0 0 1-.244.422l-1.48.422a.35.35 0 0 0-.24.24l-.423 1.481a.35.35 0 0 1-.422.244l-1.493-.375a.35.35 0 0 0-.329.088l-1.106 1.071a.35.35 0 0 1-.486 0l-1.106-1.07a.35.35 0 0 0-.329-.089l-1.493.375a.35.35 0 0 1-.422-.244l-.422-1.48a.35.35 0 0 0-.24-.24l-1.481-.423a.35.35 0 0 1-.244-.422l.375-1.493a.35.35 0 0 0-.088-.329L.236 7.243a.35.35 0 0 1 0-.486l1.07-1.106a.35.35 0 0 0 .089-.329L1.02 3.829a.35.35 0 0 1 .244-.422l1.48-.422a.35.35 0 0 0 .24-.24l.423-1.481a.35.35 0 0 1 .422-.244l1.493.375a.35.35 0 0 0 .329-.088L6.757.236Z"/>
<path fill="#fff" fillRule="evenodd" d="M9.065 4.85a.644.644 0 0 1 .899 0 .615.615 0 0 1 .053.823l-.053.059L6.48 9.15a.645.645 0 0 1-.84.052l-.06-.052-1.66-1.527a.616.616 0 0 1 0-.882.645.645 0 0 1 .84-.052l.06.052 1.21 1.086 3.034-2.978Z" clipRule="evenodd"/>
</svg>
);
}
function StarRating({ rating }: { rating: number }) {
return (
<div className="flex gap-0.5">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`h-4 w-4 ${i < rating ? "text-yellow-400 fill-yellow-400" : "text-muted-foreground/30"}`} />
))}
</div>
);
}
// ─── Types ────────────────────────────────────────────────────────────────────
type Review = typeof googleReviews[number];
// ─── Modal ────────────────────────────────────────────────────────────────────
function ReviewModal({ review, onClose }: { review: Review; onClose: () => void }) {
const initials = review.name.split(" ").map(n => n[0]).join("").toUpperCase();
useEffect(() => {
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative bg-card rounded-2xl p-8 max-w-xl w-full max-h-[85vh] overflow-y-auto shadow-2xl border border-border"
onClick={e => e.stopPropagation()}
>
<button
onClick={onClose}
className="absolute top-4 right-4 p-1.5 text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-muted"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
<div className="flex items-center gap-4 mb-6">
<div className="w-12 h-12 rounded-full bg-primary flex items-center justify-center text-primary-foreground font-bold text-lg flex-shrink-0">
{initials}
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-bold text-foreground">{review.name}</p>
<VerifiedBadge />
</div>
<p className="text-muted-foreground text-sm">{review.date}</p>
</div>
</div>
<div className="flex gap-1 mb-4">
{[...Array(5)].map((_, i) => (
<Star key={i} className={`h-6 w-6 ${i < review.rating ? "text-yellow-400 fill-yellow-400" : "text-muted-foreground/30"}`} />
))}
</div>
<p className="text-foreground leading-relaxed">{review.text}</p>
</div>
</div>
);
}
// ─── Card ─────────────────────────────────────────────────────────────────────
function ReviewCard({ review, onReadMore }: { review: Review; onReadMore: () => void }) {
const initials = review.name.split(" ").map(n => n[0]).join("").toUpperCase();
const needsTruncation = review.text.length > 120;
return (
<div className="bg-card rounded-2xl p-6 shadow-sm border border-border flex flex-col h-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-primary flex items-center justify-center text-primary-foreground font-bold text-sm flex-shrink-0">
{initials}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<p className="font-semibold text-foreground truncate">{review.name}</p>
<VerifiedBadge />
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>{review.date}</span>
<span>·</span>
<GoogleLogo className="h-3.5 w-3.5" />
</div>
</div>
</div>
<StarRating rating={review.rating} />
<p className="text-foreground/80 mt-3 leading-relaxed flex-1 text-sm">
{needsTruncation ? review.text.slice(0, 120) + "…" : review.text}
</p>
{needsTruncation && (
<button onClick={onReadMore} className="text-[#1a73e8] font-semibold text-sm mt-2 text-left hover:underline">
Read more
</button>
)}
</div>
);
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function GoogleReviewCarousel() {
const [currentIndex, setCurrentIndex] = useState(0);
const [modalReview, setModalReview] = useState<Review | null>(null);
const autoPlayRef = useRef<ReturnType<typeof setInterval> | null>(null);
const REVIEWS = googleReviews;
const desktopVisible = 3;
const maxDesktopIndex = Math.max(0, REVIEWS.length - desktopVisible);
const stopAutoPlay = useCallback(() => {
if (autoPlayRef.current) { clearInterval(autoPlayRef.current); autoPlayRef.current = null; }
}, []);
const slideTo = useCallback((i: number) => setCurrentIndex(i), []);
const next = useCallback(() => { stopAutoPlay(); slideTo(currentIndex >= maxDesktopIndex ? 0 : currentIndex + 1); }, [currentIndex, maxDesktopIndex, slideTo, stopAutoPlay]);
const prev = useCallback(() => { stopAutoPlay(); slideTo(currentIndex <= 0 ? maxDesktopIndex : currentIndex - 1); }, [currentIndex, maxDesktopIndex, slideTo, stopAutoPlay]);
const nextMobile = useCallback(() => { stopAutoPlay(); slideTo(currentIndex >= REVIEWS.length - 1 ? 0 : currentIndex + 1); }, [currentIndex, REVIEWS.length, slideTo, stopAutoPlay]);
const prevMobile = useCallback(() => { stopAutoPlay(); slideTo(currentIndex <= 0 ? REVIEWS.length - 1 : currentIndex - 1); }, [currentIndex, REVIEWS.length, slideTo, stopAutoPlay]);
useEffect(() => {
autoPlayRef.current = setInterval(() => {
setCurrentIndex(prev => prev >= maxDesktopIndex ? 0 : prev + 1);
}, 5000);
return () => stopAutoPlay();
}, [maxDesktopIndex, stopAutoPlay]);
return (
<section className="py-20 bg-background">
<div className="max-w-6xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="text-center mb-12">
<div className="flex items-center justify-center gap-3 mb-3">
<GoogleLogo className="h-8 w-8" />
<h2 className="text-3xl md:text-4xl font-bold text-foreground">Google Reviews</h2>
</div>
<div className="flex items-center justify-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<StarRating rating={5} />
<span className="font-semibold text-foreground ml-1">5.0</span>
<span className="text-muted-foreground">({REVIEWS.length} reviews)</span>
</div>
<a
href="#"
className="inline-flex items-center gap-2 px-4 py-2 bg-[#1a73e8] text-white text-sm font-semibold rounded-full hover:bg-[#1557b0] transition-colors"
>
<GoogleLogo className="h-4 w-4" />
Review Us On Google
</a>
</div>
</div>
{/* Desktop: 3 visible */}
<div className="hidden lg:block">
<div className="relative">
<div className="overflow-hidden">
<div
className="flex gap-6 transition-transform duration-500 ease-in-out"
style={{ transform: `translateX(calc(-${currentIndex} * (33.333% + 8px)))` }}
>
{REVIEWS.map((review, i) => (
<div key={i} className="flex-shrink-0" style={{ width: "calc((100% - 48px) / 3)" }}>
<ReviewCard review={review} onReadMore={() => { stopAutoPlay(); setModalReview(review); }} />
</div>
))}
</div>
</div>
<button onClick={prev} className="absolute -left-5 top-1/2 -translate-y-1/2 p-2.5 bg-card rounded-full shadow-lg border border-border hover:bg-muted transition-colors z-10" aria-label="Previous">
<ChevronLeft className="h-5 w-5 text-foreground" />
</button>
<button onClick={next} className="absolute -right-5 top-1/2 -translate-y-1/2 p-2.5 bg-card rounded-full shadow-lg border border-border hover:bg-muted transition-colors z-10" aria-label="Next">
<ChevronRight className="h-5 w-5 text-foreground" />
</button>
</div>
<div className="flex justify-center gap-1.5 mt-8">
{Array.from({ length: maxDesktopIndex + 1 }).map((_, i) => (
<button key={i} onClick={() => { stopAutoPlay(); slideTo(i); }} className={`w-2 h-2 rounded-full transition-colors ${i === currentIndex ? "bg-primary" : "bg-border"}`} aria-label={`Slide ${i + 1}`} />
))}
</div>
</div>
{/* Mobile: single card */}
<div className="lg:hidden">
<div className="overflow-hidden">
<div
className="flex transition-transform duration-500 ease-in-out"
style={{ transform: `translateX(-${currentIndex * 100}%)` }}
>
{REVIEWS.map((review, i) => (
<div key={i} className="flex-shrink-0 w-full px-1">
<ReviewCard review={review} onReadMore={() => { stopAutoPlay(); setModalReview(review); }} />
</div>
))}
</div>
</div>
<div className="flex justify-center items-center gap-4 mt-6">
<button onClick={prevMobile} className="p-2 bg-card rounded-full shadow-sm border border-border hover:bg-muted transition-colors" aria-label="Previous">
<ChevronLeft className="h-5 w-5 text-foreground" />
</button>
<div className="flex gap-1.5">
{REVIEWS.map((_, i) => (
<button key={i} onClick={() => { stopAutoPlay(); slideTo(i); }} className={`w-2 h-2 rounded-full transition-colors ${i === currentIndex ? "bg-primary" : "bg-border"}`} aria-label={`Review ${i + 1}`} />
))}
</div>
<button onClick={nextMobile} className="p-2 bg-card rounded-full shadow-sm border border-border hover:bg-muted transition-colors" aria-label="Next">
<ChevronRight className="h-5 w-5 text-foreground" />
</button>
</div>
</div>
</div>
{modalReview && <ReviewModal review={modalReview} onClose={() => setModalReview(null)} />}
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add google-carouselWhere to use it
Place this in a dedicated reviews/social-proof section, typically below your features or case studies.
In Astro (src/pages/index.astro or a layout):
import GoogleReviewCarousel from '../components/innovations/reviews/google-carousel';
<GoogleReviewCarousel client:visible />
(client:visible lazy-loads it when scrolled into view)
In Next.js (app/page.tsx or any page):
import GoogleReviewCarousel from '@/components/innovations/reviews/google-carousel';
// Drop in anywhere — it's self-contained
Swap in real reviews:
Replace the imported googleReviews data with your actual reviews array.
Each review needs: { id, name, avatar, rating, date, text, verified }
Update the "Review Us On Google" link:
Find href="#" in the component and replace with your Google Maps business URL.
Ported from: crouchingtiger project (TestimonialsSection.tsx)