Page sections
Reviews marquee
Review cards on glass slide past in a strip with no end. A moving mouse pauses it so a card can be read.
Turned up on time, worked fast and left the place spotless. Booked again for next month.
Sam, sample review
Clear price before they started and no surprises after. The kitchen looks new.
Priya, sample review
- Recommends
Easy to book, friendly on the day, and they moved every piece of furniture back where it was.
Tom, sample review
Great job on a flat that really needed it. One window missed, fixed the same week without a fuss.
Aisha, sample review
Replied within the hour and fitted us in that weekend. Would recommend to anyone.
Jordan, sample review
Code 1 file
src/gallery/reviews-marquee/ReviewsMarquee.astroAstro149 lines
---
// Review cards on glass sliding past in a strip with no end. Plain CSS: the
// cards are rendered twice and the track slides by half its width, so the
// loop has no join. A moving mouse pauses it. Reduced motion shows a still row
// you can scroll. Card widths follow the frame (container query units).
// Sample reviews with made-up names: swap in real ones, never invented ones.
interface Review { name: string; source: string; text: string; rating?: number }
interface Props { reviews?: Review[] }
const {
reviews = [
{ name: 'Sam', source: 'sample review', rating: 5, text: 'Turned up on time, worked fast and left the place spotless. Booked again for next month.' },
{ name: 'Priya', source: 'sample review', rating: 5, text: 'Clear price before they started and no surprises after. The kitchen looks new.' },
{ name: 'Tom', source: 'sample review', text: 'Easy to book, friendly on the day, and they moved every piece of furniture back where it was.' },
{ name: 'Aisha', source: 'sample review', rating: 4, text: 'Great job on a flat that really needed it. One window missed, fixed the same week without a fuss.' },
{ name: 'Jordan', source: 'sample review', rating: 5, text: 'Replied within the hour and fitted us in that weekend. Would recommend to anyone.' },
],
} = Astro.props;
// About 6 seconds per card keeps the strip slow and readable.
const duration = `${reviews.length * 6}s`;
const star = 'M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.3-4.1 5.9-.9z';
---
<div class="reviews">
<div class="marquee" aria-label="Customer reviews" role="region" data-marquee>
<div class="track" style={`--duration: ${duration}`}>
{[0, 1].map((copy) => (
<ul class="group" aria-hidden={copy === 1 ? 'true' : undefined} inert={copy === 1}>
{reviews.map((review) => (
<li class="card glass">
<svg class="quote" viewBox="0 0 24 24" aria-hidden="true"><path d="M10 7H6.5A1.5 1.5 0 0 0 5 8.5V12h4v1a3 3 0 0 1-3 3M19 7h-3.5A1.5 1.5 0 0 0 14 8.5V12h4v1a3 3 0 0 1-3 3" /></svg>
{review.rating !== undefined ? (
<p class="stars" role="img" aria-label={`${review.rating} out of 5 stars`}>
{[1, 2, 3, 4, 5].map((n) => (
<svg viewBox="0 0 24 24" aria-hidden="true" class:list={{ on: n <= review.rating! }}><path d={star} /></svg>
))}
</p>
) : (
<span class="recommends">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 10v10H4V10zM7 10l4-7a2 2 0 0 1 2.6 2.5L12.5 9H19a2 2 0 0 1 2 2.3l-1.2 6.8a2 2 0 0 1-2 1.9H7" /></svg>
Recommends
</span>
)}
{/* Clamped so one long review does not dominate. */}
<p class="text">{review.text}</p>
<p class="who">{review.name}, {review.source}</p>
</li>
))}
</ul>
))}
</div>
</div>
</div>
<script>
// Pause only while a mouse is really moving over the strip; resume when it leaves.
for (const marquee of document.querySelectorAll<HTMLElement>('[data-marquee]')) {
marquee.addEventListener('pointermove', (event) => {
if (event.pointerType === 'mouse') marquee.classList.add('paused');
});
marquee.addEventListener('pointerleave', () => marquee.classList.remove('paused'));
// Off screen, the strip stops instead of sliding where no one can see it.
new IntersectionObserver(([e]) => marquee.classList.toggle('offscreen', !e?.isIntersecting)).observe(marquee);
}
</script>
<style>
.reviews {
--background: #0a0a0a; --foreground: #f5f1e6; --primary: #c9a227; --primary-foreground: #0a0a0a;
--secondary: #1f1d16; --muted-foreground: #a8a293;
position: absolute; inset: 0;
display: grid; align-content: center;
color: var(--foreground);
/* A warm gold glow behind the strip, so the glass cards have something to bend. */
background:
radial-gradient(60% 70% at 20% 60%, color-mix(in oklab, var(--primary) 16%, transparent), transparent 70%),
radial-gradient(50% 60% at 85% 40%, color-mix(in oklab, var(--primary) 10%, transparent), transparent 70%),
linear-gradient(var(--background), var(--secondary) 50%, var(--background));
}
.glass {
background: color-mix(in oklab, var(--background) 55%, transparent);
-webkit-backdrop-filter: blur(18px) saturate(170%);
backdrop-filter: blur(18px) saturate(170%);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--foreground) 14%, transparent),
inset 0 -1px 0 color-mix(in oklab, var(--primary) 18%, transparent);
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
.glass { background: color-mix(in oklab, var(--background) 94%, transparent); }
}
.marquee {
container-type: inline-size;
overflow: hidden;
padding-block: 1rem 2rem;
/* The strip fades out at both edges instead of being cut. */
mask-image: linear-gradient(90deg, transparent, var(--background) 6%, var(--background) 94%, transparent);
}
.track {
display: flex;
width: max-content;
animation: marquee var(--duration) linear infinite;
}
/* Paused by the script, not :hover: :hover also fires when the page scrolls the strip
under a mouse that is not moving, which left the strip looking stuck. */
.marquee:global(.paused) .track,
.marquee:global(.offscreen) .track { animation-play-state: paused; }
/* Each copy carries its own trailing gap, so sliding by exactly half lands on the start. */
.group { display: flex; align-items: stretch; gap: 1rem; padding: 0 1rem 0 0; margin: 0; list-style: none; }
.card {
position: relative; overflow: hidden;
width: 85cqw; flex-shrink: 0;
padding: 1.5rem; border-radius: 0.75rem;
border: 1px solid color-mix(in oklab, var(--primary) 35%, transparent);
box-shadow:
inset 0 1px 0 color-mix(in oklab, var(--foreground) 14%, transparent),
0 18px 40px -20px color-mix(in oklab, var(--primary) 40%, transparent);
}
@container (min-width: 560px) { .card { width: calc((100cqw - 2rem) / 2.4); } }
@container (min-width: 900px) { .card { width: calc((100cqw - 3rem) / 3.4); } }
svg { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.quote { position: absolute; right: 1rem; top: 1rem; width: 3.5rem; height: 3.5rem; color: color-mix(in oklab, var(--primary) 15%, transparent); stroke-width: 2.2; }
.stars { display: flex; margin: 0; color: var(--primary); }
.stars svg { width: 1.25rem; height: 1.25rem; }
.stars svg.on { fill: currentColor; }
.recommends {
display: inline-flex; align-items: center; gap: 0.375rem; width: fit-content;
padding: 0.25rem 0.75rem; border-radius: 999px;
background: var(--primary); color: var(--primary-foreground);
font-size: 0.875rem; font-weight: 500;
}
.recommends svg { width: 1rem; height: 1rem; fill: currentColor; stroke: none; }
.text {
margin: 1rem 0 0; font-size: 1.125rem; line-height: 1.6;
display: -webkit-box; -webkit-line-clamp: 6; -webkit-box-orient: vertical; overflow: hidden;
}
.who { margin: 0.75rem 0 0; font-size: 0.875rem; color: var(--muted-foreground); }
@keyframes marquee { to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) {
.marquee { overflow-x: auto; scroll-snap-type: x mandatory; mask-image: none; }
.track { animation: none; }
.group[aria-hidden] { display: none; }
.card { scroll-snap-align: start; }
}
</style>