reviews /
Review Marquee
Infinitely scrolling dual-row marquee of review cards — top row scrolls left, bottom scrolls right. Pure CSS animation, pauses on hover.
Preview
Source
tsx
"use client";
import { Star } from "lucide-react";
import { testimonials } from "@/lib/placeholders";
// Double the list for seamless looping
const row1 = [...testimonials, ...testimonials];
const row2 = [...testimonials, ...testimonials].reverse();
function ReviewCard({
name,
avatar,
rating,
text,
}: {
name: string;
avatar: string;
rating: number;
text: string;
}) {
return (
<div className="flex-shrink-0 w-72 sm:w-80 bg-card border border-border rounded-2xl p-5 shadow-sm mx-3">
<div className="flex items-center gap-3 mb-3">
<img
src={avatar}
alt={name}
className="w-9 h-9 rounded-full object-cover flex-shrink-0"
/>
<div>
<p className="font-semibold text-sm text-foreground">{name}</p>
<div className="flex gap-0.5 mt-0.5">
{Array.from({ length: 5 }).map((_, i) => (
<Star
key={i}
className={`w-3 h-3 ${
i < rating
? "fill-amber-400 text-amber-400"
: "fill-muted text-muted"
}`}
/>
))}
</div>
</div>
</div>
<p className="text-sm text-muted-foreground leading-relaxed line-clamp-3">
{text}
</p>
</div>
);
}
export default function ReviewMarquee() {
return (
<section className="py-20 bg-background overflow-hidden">
{/* Keyframe styles */}
<style>{`
@keyframes marquee-left {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@keyframes marquee-right {
0% { transform: translateX(-50%); }
100% { transform: translateX(0); }
}
.marquee-track-left {
animation: marquee-left 30s linear infinite;
}
.marquee-track-right {
animation: marquee-right 25s linear infinite;
}
.marquee-viewport:hover .marquee-track-left,
.marquee-viewport:hover .marquee-track-right {
animation-play-state: paused;
}
`}</style>
{/* Header */}
<div className="text-center mb-12 px-4">
<span className="inline-block text-xs font-semibold uppercase tracking-widest text-primary bg-primary/10 border border-primary/20 rounded-full px-4 py-1.5 mb-4">
Reviews
</span>
<h2 className="text-3xl sm:text-4xl font-extrabold tracking-tight text-foreground mb-3">
Trusted by hundreds of clients
</h2>
<p className="text-lg text-muted-foreground max-w-xl mx-auto">
Real words from real people who've experienced the difference.
</p>
</div>
{/* Marquee container with edge fade */}
<div
className="marquee-viewport relative"
style={{
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
maskImage:
"linear-gradient(to right, transparent 0%, black 10%, black 90%, transparent 100%)",
}}
>
{/* Row 1 — scrolls left */}
<div className="flex mb-4 overflow-visible">
<div className="marquee-track-left flex">
{row1.map((t, i) => (
<ReviewCard key={`r1-${t.id}-${i}`} name={t.name} avatar={t.avatar} rating={t.rating} text={t.text} />
))}
</div>
</div>
{/* Row 2 — scrolls right */}
<div className="flex overflow-visible">
<div className="marquee-track-right flex">
{row2.map((t, i) => (
<ReviewCard key={`r2-${t.id}-${i}`} name={t.name} avatar={t.avatar} rating={t.rating} text={t.text} />
))}
</div>
</div>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add review-marqueeWhere to use it
Works great on homepages between sections as a visual break and social proof moment. No JavaScript required — pure CSS keyframe animation.
In Astro:
import ReviewMarquee from '../components/innovations/reviews/review-marquee';
<ReviewMarquee client:load />
(client:visible recommended for below-the-fold performance)
In Next.js:
import ReviewMarquee from '@/components/innovations/reviews/review-marquee';
// Place between the hero and features, or between features and CTA
The marquee pauses on hover so users can read cards. The two rows run at slightly different speeds for visual interest. Edit testimonials in src/lib/placeholders.ts.