UI Component
✨ NewMarketing

Infinite Marquee Testimonial Wall

A warm, editorial testimonial section with three rows of review cards scrolling in opposite directions, pause-on-hover, star ratings, highlighted key phrases, generated avatars and animated count-up stats. Testimonials are rendered from a simple JavaScript array, so adding reviews is easy.

September 27, 20261 views
  • Testimonial
  • testimonial
  • marquee
  • reviews
  • social-proof
  • landing-page
  • infinite-scroll
Infinite Marquee Testimonial Wall
Free

Download instantly, no signup required.

Features Included

Three marquee rows moving in alternating directions at different speeds
Pause on hover so visitors can read a card
Star ratings, source badge and highlighted key phrases
Initial-based gradient avatars (no images needed)
Count-up stats that animate when scrolled into view
Edit one JavaScript array to change all testimonials
Dependencies
Google Fonts — DM Sans + Instrument Serifcdn

Infinite Marquee Testimonial Wall

A 'wall of love' section that builds instant trust — three infinite rows of reviews, count-up stats and a clean editorial look. HTML, CSS and vanilla JavaScript.


What's Included

  • Three marquee rows moving in alternating directions at different speeds
  • Pause on hover so visitors can read a card
  • Star ratings, source badge and highlighted key phrases
  • Initial-based gradient avatars (no images needed)
  • Count-up stats that animate when scrolled into view
  • Edit one JavaScript array to change all testimonials

Dependencies

Already linked inside the component's files — no extra setup needed.


Desktop Preview


HTML

html
1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
5    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6    <title>Infinite Testimonial Wall | MetaSkillGroup</title>
7    <link rel="stylesheet" href="style.css" />
8  </head>
9  <body>
10    <!-- created by metaskillgroup-team -->
11    <section class="testimonials">
12      <header class="t-head">
13        <span class="eyebrow">Wall of love</span>
14        <h1>Don't take our word for it.<br /><em>Take theirs.</em></h1>
15        <div class="t-stats">
16          <div><strong data-count="12000">0</strong><span>happy customers</span></div>
17          <div><strong data-count="4.9" data-decimals="1">0</strong><span>average rating</span></div>
18          <div><strong data-count="98" data-suffix="%">0</strong><span>would recommend</span></div>
19        </div>
20      </header>
21
22      <!-- Rows are filled from script.js -->
23      <div class="wall">
24        <div class="row" data-direction="left"><div class="track"></div></div>
25        <div class="row" data-direction="right"><div class="track"></div></div>
26        <div class="row" data-direction="left"><div class="track"></div></div>
27      </div>
28
29      <div class="t-cta">
30        <a href="#" class="btn">Read all 2,300 reviews</a>
31        <a href="#" class="btn btn-outline">Share your story</a>
32      </div>
33    </section>
34
35    <script src="script.js"></script>
36  </body>
37</html>
38

CSS

css
1/* Importing Google Fonts - DM Sans + Instrument Serif */
2@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap");
3
4* {
5  margin: 0;
6  padding: 0;
7  box-sizing: border-box;
8  font-family: "DM Sans", sans-serif;
9}
10
11:root {
12  --bg: #faf6ef;
13  --card: #ffffff;
14  --text: #1c1917;
15  --muted: #78716c;
16  --border: #ebe4d8;
17  --accent: #ea580c;
18  --star: #f59e0b;
19}
20
21body {
22  background: var(--bg);
23  color: var(--text);
24  overflow-x: hidden;
25}
26
27a {
28  color: inherit;
29  text-decoration: none;
30}
31
32.testimonials {
33  padding: 90px 0 100px;
34}
35
36/* ---------- Heading ---------- */
37.t-head {
38  max-width: 820px;
39  margin: 0 auto 56px;
40  padding: 0 20px;
41  text-align: center;
42}
43
44.eyebrow {
45  display: inline-flex;
46  align-items: center;
47  gap: 8px;
48  font-size: 0.82rem;
49  font-weight: 700;
50  letter-spacing: 0.12em;
51  text-transform: uppercase;
52  color: var(--accent);
53}
54
55.eyebrow::before,
56.eyebrow::after {
57  content: "";
58  width: 24px;
59  height: 1px;
60  background: currentColor;
61}
62
63.t-head h1 {
64  margin: 18px 0 36px;
65  font-family: "Instrument Serif", serif;
66  font-weight: 400;
67  font-size: clamp(2.4rem, 6vw, 4.2rem);
68  line-height: 1.05;
69  letter-spacing: -0.01em;
70}
71
72.t-head h1 em {
73  color: var(--accent);
74}
75
76.t-stats {
77  display: flex;
78  justify-content: center;
79  gap: clamp(20px, 6vw, 64px);
80  flex-wrap: wrap;
81}
82
83.t-stats div {
84  display: flex;
85  flex-direction: column;
86  align-items: center;
87}
88
89.t-stats strong {
90  font-family: "Instrument Serif", serif;
91  font-size: 2.6rem;
92  font-weight: 400;
93  line-height: 1;
94  font-variant-numeric: tabular-nums;
95}
96
97.t-stats span {
98  margin-top: 6px;
99  font-size: 0.85rem;
100  color: var(--muted);
101}
102
103/* ---------- Marquee wall ---------- */
104.wall {
105  display: flex;
106  flex-direction: column;
107  gap: 20px;
108  /* Fade the left and right edges */
109  mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
110  -webkit-mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
111}
112
113.row {
114  overflow: hidden;
115}
116
117.track {
118  display: flex;
119  gap: 20px;
120  width: max-content;
121  padding: 6px 0;
122  animation: marquee var(--duration, 50s) linear infinite;
123}
124
125.row[data-direction="right"] .track {
126  animation-direction: reverse;
127}
128
129/* Pause a row while the user reads a card */
130.row:hover .track {
131  animation-play-state: paused;
132}
133
134@keyframes marquee {
135  to {
136    transform: translateX(calc(-50% - 10px));
137  }
138}
139
140/* ---------- Card ---------- */
141.t-card {
142  position: relative;
143  width: 360px;
144  flex-shrink: 0;
145  padding: 24px;
146  border-radius: 20px;
147  background: var(--card);
148  border: 1px solid var(--border);
149  box-shadow: 0 1px 2px rgba(28, 25, 23, 0.04);
150  transition:
151    transform 0.3s ease,
152    box-shadow 0.3s ease,
153    border-color 0.3s ease;
154}
155
156.t-card:hover {
157  transform: translateY(-4px) rotate(-0.6deg);
158  border-color: #f3c9a8;
159  box-shadow: 0 20px 40px rgba(234, 88, 12, 0.12);
160}
161
162.t-card::after {
163  content: "\201C";
164  position: absolute;
165  top: 6px;
166  right: 20px;
167  font-family: "Instrument Serif", serif;
168  font-size: 5rem;
169  line-height: 1;
170  color: #f5e6d6;
171  pointer-events: none;
172}
173
174.t-stars {
175  color: var(--star);
176  letter-spacing: 2px;
177  font-size: 0.95rem;
178}
179
180.t-stars .dim {
181  color: #e7e0d4;
182}
183
184.t-text {
185  position: relative;
186  z-index: 1;
187  margin: 12px 0 20px;
188  font-size: 0.98rem;
189  line-height: 1.6;
190}
191
192.t-text mark {
193  background: linear-gradient(transparent 60%, #fed7aa 60%);
194  color: inherit;
195}
196
197.t-user {
198  display: flex;
199  align-items: center;
200  gap: 12px;
201}
202
203.t-avatar {
204  display: grid;
205  place-items: center;
206  width: 42px;
207  height: 42px;
208  border-radius: 50%;
209  font-size: 0.85rem;
210  font-weight: 700;
211  color: #fff;
212  background: linear-gradient(135deg, hsl(var(--h) 75% 60%), hsl(calc(var(--h) + 40) 75% 50%));
213}
214
215.t-user strong {
216  display: block;
217  font-size: 0.93rem;
218}
219
220.t-user small {
221  font-size: 0.8rem;
222  color: var(--muted);
223}
224
225.t-source {
226  margin-left: auto;
227  padding: 4px 10px;
228  border-radius: 99px;
229  font-size: 0.72rem;
230  font-weight: 600;
231  color: var(--muted);
232  background: var(--bg);
233}
234
235/* ---------- CTA ---------- */
236.t-cta {
237  display: flex;
238  justify-content: center;
239  flex-wrap: wrap;
240  gap: 12px;
241  margin-top: 56px;
242  padding: 0 20px;
243}
244
245.btn {
246  padding: 14px 26px;
247  border-radius: 99px;
248  font-weight: 600;
249  color: #fff;
250  background: var(--text);
251  transition:
252    transform 0.2s ease,
253    background 0.2s ease;
254}
255
256.btn:hover {
257  transform: translateY(-2px);
258  background: var(--accent);
259}
260
261.btn-outline {
262  color: var(--text);
263  background: transparent;
264  border: 1.5px solid var(--text);
265}
266
267.btn-outline:hover {
268  color: #fff;
269  border-color: var(--accent);
270}
271
272/* ---------- Responsive ---------- */
273@media (max-width: 640px) {
274  .t-card {
275    width: 290px;
276    padding: 20px;
277  }
278
279  .wall {
280    gap: 14px;
281  }
282
283  .track {
284    gap: 14px;
285  }
286}
287
288@media (prefers-reduced-motion: reduce) {
289  .track {
290    animation: none;
291  }
292
293  .row {
294    overflow-x: auto;
295  }
296}
297

JavaScript

javascript
1// Edit this list to change the testimonials
2const testimonials = [
3  {
4    name: "Aarav Mehta",
5    role: "CTO, Finlytics",
6    rating: 5,
7    source: "G2",
8    text: "We moved our entire team over in a weekend. <mark>Onboarding took less than an hour</mark> and nobody has looked back.",
9  },
10  {
11    name: "Sophie Laurent",
12    role: "Product Designer",
13    rating: 5,
14    source: "Twitter",
15    text: "Finally a tool that feels as good as the things we design with it. <mark>The attention to detail is unreal.</mark>",
16  },
17  {
18    name: "Marcus Chen",
19    role: "Founder, Shipfast",
20    rating: 5,
21    source: "Product Hunt",
22    text: "It replaced four different subscriptions for us. <mark>We save around $900 every month.</mark>",
23  },
24  {
25    name: "Priya Nair",
26    role: "Engineering Manager",
27    rating: 4,
28    source: "Capterra",
29    text: "Sprint planning used to take half a day. Now it's a 20-minute call and <mark>everyone knows what to do</mark>.",
30  },
31  {
32    name: "Lucas Oliveira",
33    role: "Freelance Developer",
34    rating: 5,
35    source: "Twitter",
36    text: "The API is clean, the docs are great and support replied in <mark>under five minutes</mark>. Rare these days.",
37  },
38  {
39    name: "Hannah Kim",
40    role: "Head of Marketing",
41    rating: 5,
42    source: "G2",
43    text: "Our campaign launches are <mark>twice as fast</mark> since we started using the shared boards.",
44  },
45  {
46    name: "Omar Haddad",
47    role: "Startup Advisor",
48    rating: 5,
49    source: "LinkedIn",
50    text: "I recommend it to every founder I work with. It just <mark>gets out of your way</mark> and lets you ship.",
51  },
52  {
53    name: "Emily Watson",
54    role: "Ops Lead, Brightside",
55    rating: 4,
56    source: "Capterra",
57    text: "Automations alone saved us <mark>12 hours a week</mark>. The reporting is the cherry on top.",
58  },
59  {
60    name: "Rohan Gupta",
61    role: "Full-stack Engineer",
62    rating: 5,
63    source: "Product Hunt",
64    text: "Dark mode, keyboard shortcuts, instant search. <mark>Built by people who actually use it.</mark>",
65  },
66  {
67    name: "Isabella Rossi",
68    role: "Creative Director",
69    rating: 5,
70    source: "LinkedIn",
71    text: "Clients love the shareable previews. <mark>Approvals that took days now take minutes.</mark>",
72  },
73  {
74    name: "Daniel Okafor",
75    role: "CEO, Paystack Labs",
76    rating: 5,
77    source: "G2",
78    text: "Scaled from 5 to 120 people without changing tools once. <mark>It grows with you.</mark>",
79  },
80  {
81    name: "Mia Johansson",
82    role: "UX Researcher",
83    rating: 5,
84    source: "Twitter",
85    text: "I've tried everything out there. <mark>This is the first one that stuck</mark> for more than a month.",
86  },
87];
88
89const rows = document.querySelectorAll(".row");
90
91// Build the HTML for one testimonial card
92const cardHTML = (t) => {
93  const initials = t.name
94    .split(" ")
95    .map((n) => n[0])
96    .join("");
97  // Stable hue per person so avatar colours don't change on reload
98  const hue = [...t.name].reduce((sum, ch) => sum + ch.charCodeAt(0), 0) % 360;
99  const stars =
100    "★".repeat(t.rating) +
101    `<span class="dim">${"★".repeat(5 - t.rating)}</span>`;
102
103  return `
104    <figure class="t-card">
105      <div class="t-stars" aria-label="${t.rating} out of 5 stars">${stars}</div>
106      <blockquote class="t-text">${t.text}</blockquote>
107      <figcaption class="t-user">
108        <span class="t-avatar" style="--h: ${hue}">${initials}</span>
109        <span><strong>${t.name}</strong><small>${t.role}</small></span>
110        <span class="t-source">${t.source}</span>
111      </figcaption>
112    </figure>`;
113};
114
115// Split testimonials across rows, each row starting at a different offset
116rows.forEach((row, i) => {
117  const track = row.querySelector(".track");
118  const offset = i * 4;
119  const list = [
120    ...testimonials.slice(offset),
121    ...testimonials.slice(0, offset),
122  ];
123  const html = list.map(cardHTML).join("");
124
125  // Content twice so translateX(-50%) loops seamlessly
126  track.innerHTML = html + html;
127  track.style.setProperty("--duration", `${60 + i * 12}s`);
128  [...track.children]
129    .slice(list.length)
130    .forEach((el) => el.setAttribute("aria-hidden", "true"));
131});
132
133// Count-up stats when they scroll into view
134const counters = document.querySelectorAll("[data-count]");
135
136const runCounter = (el) => {
137  const target = Number(el.dataset.count);
138  const decimals = Number(el.dataset.decimals || 0);
139  const suffix = el.dataset.suffix || "";
140  const duration = 1600;
141  const start = performance.now();
142
143  const step = (now) => {
144    const p = Math.min((now - start) / duration, 1);
145    const value = target * (1 - Math.pow(1 - p, 4));
146    el.textContent =
147      (decimals
148        ? value.toFixed(decimals)
149        : Math.round(value).toLocaleString()) +
150      (p === 1 && target >= 1000 ? "+" : "") +
151      suffix;
152    if (p < 1) requestAnimationFrame(step);
153  };
154  requestAnimationFrame(step);
155};
156
157const observer = new IntersectionObserver(
158  (entries) => {
159    entries.forEach((entry) => {
160      if (entry.isIntersecting) {
161        runCounter(entry.target);
162        observer.unobserve(entry.target);
163      }
164    });
165  },
166  { threshold: 0.6 },
167);
168
169counters.forEach((el) => observer.observe(el));
170

Built by Meta Skill Group — copy it, use it, make it yours.

Built With

🌐

HTML5

🎨

CSS3

⚡

JavaScript

Compatibility

Browsers
ChromeOperaBraveFirefoxSafariArcVivaldiEdge
Devices
DesktopSmartwatchTVTabletMobile
Frameworks
Vanilla JSHTML/CSS

Found this useful?

MetaSkillGroup

Written by

MetaSkillGroup

September 27, 2026

❓ FAQs

Subscribe to Newsletter

Get latest updates, tutorials and resources delivered to your inbox.

No spam. Unsubscribe anytime.

Infinite Testimonial Wall | HTML CSS JS Marquee | MetaSkillGroup