Testimonial Carousel — Free HTML CSS JS Slider Snippet
Testimonial Carousel · Carousels · Plain HTML, CSS & JS · Live preview
Copy & paste
Source Code
<div class="tc-carousel" id="tcCarousel" aria-roledescription="carousel" aria-label="Customer testimonials">
<div class="tc-viewport">
<div class="tc-track" id="tcTrack"></div>
</div>
<div class="tc-controls">
<button class="tc-arrow" id="tcPrev" type="button" aria-label="Previous testimonial">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<div class="tc-dots" id="tcDots" role="tablist" aria-label="Choose testimonial"></div>
<button class="tc-arrow" id="tcNext" type="button" aria-label="Next testimonial">
<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, -apple-system, sans-serif; background: #f8fafc; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 32px; }
.tc-carousel { width: 100%; max-width: 460px; }
.tc-viewport { overflow: hidden; border-radius: 18px; }
.tc-track {
display: flex;
transition: transform 0.5s cubic-bezier(0.4, 0.2, 0.2, 1);
will-change: transform;
}
.tc-slide {
flex: 0 0 100%;
padding: 34px 32px 30px;
background: #fff;
border: 1px solid #e8edf3;
}
.tc-stars { display: flex; gap: 3px; margin-bottom: 16px; }
.tc-stars svg { width: 17px; height: 17px; fill: #f59e0b; }
.tc-text {
font-size: 17px;
line-height: 1.6;
color: #1e293b;
font-weight: 500;
letter-spacing: -0.01em;
min-height: 110px;
}
.tc-author { display: flex; align-items: center; gap: 12px; margin-top: 22px; }
.tc-avatar {
width: 44px; height: 44px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 15px; font-weight: 700;
border-radius: 50%;
}
.tc-meta { display: flex; flex-direction: column; }
.tc-name { font-size: 14.5px; font-weight: 700; color: #0f172a; }
.tc-role { font-size: 12.5px; color: #94a3b8; }
.tc-controls { display: flex; align-items: center; justify-content: center; gap: 18px; margin-top: 22px; }
.tc-arrow {
width: 40px; height: 40px;
display: flex; align-items: center; justify-content: center;
background: #fff; border: 1px solid #e2e8f0; border-radius: 50%;
cursor: pointer; color: #475569;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.tc-arrow svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.tc-arrow:hover { border-color: #6366f1; color: #6366f1; }
.tc-dots { display: flex; gap: 8px; }
.tc-dot {
width: 8px; height: 8px; padding: 0;
background: #cbd5e1; border: none; border-radius: 999px;
cursor: pointer;
transition: width 0.3s, background 0.3s;
}
.tc-dot.active { width: 24px; background: #6366f1; }const DATA = [
{ text: 'We shipped our redesign three weeks early. The component library alone saved us a month of work — everything just dropped in and worked.', name: 'Maya Chen', role: 'Head of Product, Linework', rating: 5, color: 'linear-gradient(135deg,#6366f1,#8b5cf6)' },
{ text: 'Support actually reads your messages. I reported an edge case at 11pm and had a fix in the next release. That never happens.', name: 'Devon Park', role: 'CTO, Halcyon', rating: 5, color: 'linear-gradient(135deg,#0ea5e9,#22d3ee)' },
{ text: 'Onboarding took ten minutes. Our whole team was productive on day one without a single training call.', name: 'Priya Nair', role: 'Eng Manager, Foundry', rating: 4, color: 'linear-gradient(135deg,#16a34a,#84cc16)' },
{ text: 'The pricing is honest and the value is obvious. We compared four tools and this was the only one that did not nickel-and-dime us.', name: 'Tom Alvarez', role: 'Founder, Skiff', rating: 5, color: 'linear-gradient(135deg,#f97316,#ec4899)' },
];
const track = document.getElementById('tcTrack');
const dotsWrap = document.getElementById('tcDots');
const carousel = document.getElementById('tcCarousel');
let index = 0;
let timer = null;
function starSvg() {
return '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
}
DATA.forEach((t, i) => {
const slide = document.createElement('div');
slide.className = 'tc-slide';
const initials = t.name.split(' ').map(w => w[0]).join('').slice(0, 2);
const stars = Array.from({ length: 5 }, (_, s) => s < t.rating ? starSvg() : '').join('');
slide.innerHTML =
'<div class="tc-stars">' + stars + '</div>' +
'<p class="tc-text">' + t.text + '</p>' +
'<div class="tc-author">' +
'<span class="tc-avatar" style="background:' + t.color + '">' + initials + '</span>' +
'<span class="tc-meta"><span class="tc-name">' + t.name + '</span><span class="tc-role">' + t.role + '</span></span>' +
'</div>';
track.appendChild(slide);
const dot = document.createElement('button');
dot.className = 'tc-dot' + (i === 0 ? ' active' : '');
dot.type = 'button';
dot.setAttribute('role', 'tab');
dot.setAttribute('aria-label', 'Testimonial ' + (i + 1));
dot.addEventListener('click', () => { goTo(i); restart(); });
dotsWrap.appendChild(dot);
});
function goTo(i) {
index = (i + DATA.length) % DATA.length;
track.style.transform = 'translateX(-' + (index * 100) + '%)';
[...dotsWrap.children].forEach((d, di) => d.classList.toggle('active', di === index));
}
function restart() {
clearInterval(timer);
timer = setInterval(() => goTo(index + 1), 5000);
}
document.getElementById('tcPrev').addEventListener('click', () => { goTo(index - 1); restart(); });
document.getElementById('tcNext').addEventListener('click', () => { goTo(index + 1); restart(); });
// Pause autoplay on hover / focus
carousel.addEventListener('mouseenter', () => clearInterval(timer));
carousel.addEventListener('mouseleave', restart);
carousel.addEventListener('focusin', () => clearInterval(timer));
carousel.addEventListener('focusout', restart);
restart();Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month, 7 days free), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Testimonial Carousel — Autoplay Slider with Stars, Avatars and Animated Dots

Social proof sells, but a wall of testimonials is overwhelming and a single static quote is forgettable. The testimonial carousel solves both problems: it shows one customer quote at a time, full and readable, then advances automatically so visitors see several without scrolling. This component is a complete, accessible carousel built with a CSS transform track, autoplay that pauses on interaction, arrow controls, and animated progress dots — all in plain HTML, CSS, and vanilla JavaScript with no slider library.
The transform-based track
The mechanics are a flex track inside an overflow: hidden viewport. Every slide has flex: 0 0 100%, so each one is exactly the viewport's width and they line up in a horizontal row. Sliding is a single transform: translateX(-N%) on the track, where N is the active index times 100 — moving to slide 2 translates the track by -100%, slide 3 by -200%, and so on. Because it animates only transform, the slide runs on the GPU compositor at 60fps with no layout work, and will-change: transform hints the browser to promote the track to its own layer ahead of time.
Data-driven slides and ratings
The carousel is built from a DATA array of { text, name, role, rating, color } objects. On load the script loops the array, builds each slide's markup, and derives two things automatically: the author's initials (by splitting the name and taking each word's first letter) for the gradient avatar, and the star row (by rendering a filled star SVG for each point up to rating out of five). This means adding a testimonial is a single array entry — no markup edits — and the four-star example shows partial ratings render correctly.
Autoplay with pause-on-interaction
A setInterval advances the carousel every 5 seconds by calling goTo(index + 1). The crucial UX detail is that autoplay yields to the user: mouseenter and focusin clear the interval so the carousel stops while someone is reading or tabbing through it, and mouseleave and focusout restart it. Every manual action — clicking an arrow or a dot — also calls restart(), which clears and re-creates the timer so the full 5 seconds is given to the slide the user just chose rather than whatever was left on the previous tick.
Infinite wrap-around
The goTo() function normalises any index with (i + length) % length, so advancing past the last slide wraps to the first and going back from the first wraps to the last. The arrows and autoplay all route through this one function, which means there is a single source of truth for the current index, the track transform, and the active dot — no chance of the dots drifting out of sync with the visible slide.
Animated progress dots
The dots are real <button> elements with role="tab". The active dot is not just a different colour — it stretches from a circle to a pill with width: 24px via a CSS transition on width, giving a subtle, modern progress indicator. Clicking any dot jumps straight to that slide and restarts autoplay. The arrows are circular icon buttons with hover states that adopt the accent colour, and everything carries appropriate aria-label and aria-roledescription attributes so the carousel is understandable to screen readers.
Customisation
To use it, replace the DATA array with your real testimonials — set each rating from 1 to 5, give each author a gradient color, and the avatars and stars render automatically. Adjust the 5000ms autoplay interval, change the 0.5s slide transition duration or easing, and swap the #6366f1 accent (active dot, arrow hover, avatars) for your brand. To show real photos instead of initials, render an <img> in place of the .tc-avatar span. For a multi-card view, change the slide's flex-basis to 50% or 33.33% and step the transform by that amount.
Step by step
How to Use
- 1Paste the HTML, CSS, and JSA testimonial card renders with a star rating, quote, author, arrow controls, and a row of dots; it begins autoplaying every 5 seconds.
- 2Use the arrowsClick the left/right arrows to move between testimonials; the track slides smoothly and autoplay timing resets.
- 3Click a dotJump directly to any testimonial; the active dot stretches into a pill to mark your position.
- 4Hover to pauseMove the pointer over the carousel (or tab into it) and autoplay pauses so you can read; it resumes when you leave.
- 5Replace the testimonialsEdit the DATA array — each entry's rating drives the stars and the name drives the avatar initials automatically.
- 6Theme and time itSwap the #6366f1 accent and adjust the 5000ms interval and 0.5s slide duration to fit your brand and pace.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The interval is set in restart() as setInterval(..., 5000) — change 5000 to your preferred milliseconds. To disable autoplay entirely, remove the restart() call at the bottom and the mouseenter/leave/focus handlers, leaving only the arrows and dots for manual control. The slide animation speed is separate: edit the 0.5s transition on .tc-track.
Change the slide's flex-basis from 100% to 50% (two up) or 33.33% (three up) in .tc-slide, and update goTo() to translate by index * (100 / perView) percent. You may also want the dots to represent pages rather than individual slides. The autoplay, wrap-around, and pause logic stay the same.
Every manual navigation calls restart(), which clears and recreates the timer. This gives the slide you just selected the full interval before it advances, rather than the leftover fraction of the previous tick — so the carousel never jumps away a moment after you click. It is a small detail that makes manual control feel responsive.
Replace the <span class="tc-avatar">initials</span> in the slide template with <img class="tc-avatar" src={t.img} alt={t.name}> and add object-fit: cover to the .tc-avatar rule so the photo fills the circle. Add an img field to each DATA entry. Keep the initials version as a fallback for testimonials without a photo.
Keep the index in state and bind the track's transform to translateX(-index*100%). Render DATA with .map/v-for/*ngFor. Run autoplay in an effect that sets an interval and clears it on cleanup (useEffect return, onUnmounted, ngOnDestroy) — this is essential to avoid leaked timers. Pause handlers map to onMouseEnter/onMouseLeave (and onFocus/onBlur) that clear and restart the interval; arrows and dots call your goTo(i) setter.