I’m building a carousel that shows:
A center “active” card
A left and right “peek” of adjacent cards (clipped/masked)
Prev/next buttons + dots
It works functionally, but the animation doesn’t feel smooth like the “Thousands of Businesses Transformed” carousel on this page. How can I mimic this behavior?
https://www.ramseysolutions.com/business/entreleadership
Right now my JS replaces innerHTML inside the slots on every navigation. This causes a “snap” / jank, and I can’t get a smooth slide/crossfade between the old and new cards. I also sometimes see a brief “extra card under” effect if two .ts-slot-inner elements exist during transitions.
function initCarousel(root) {
const articles = Array.from(root.querySelectorAll(".slides > article"));
const prevSlot = root.querySelector('[data-slot="prev"]');
const currSlot = root.querySelector('[data-slot="current"]');
const nextSlot = root.querySelector('[data-slot="next"]');
const prevBtn = root.querySelector("[data-prev]");
const nextBtn = root.querySelector("[data-next]");
const dotsWrap = root.querySelector("[data-dots]");
const slides = articles.map(a => ({
title: a.dataset.title || "Slide",
html: a.innerHTML
}));
const mod = (n, m) => ((n % m) + m) % m;
const wrapSlot = (html) => `<div class="slot-inner">${html}</div>`;
const wrapPeek = (html) =>
`<div class="slot-inner"><div class="peek-inner">${html}</div></div>`;
let index = 0;
function renderDots() {
dotsWrap.innerHTML = slides.map((s, i) => `
<button type="button" data-dot="${i}" aria-selected="${i === index}">
<span class="sr-only">${s.title}</span>
</button>
`).join("");
dotsWrap.querySelectorAll("[data-dot]").forEach(btn => {
btn.addEventListener("click", () => {
index = Number(btn.dataset.dot);
update();
});
});
}
function update() {
const prevIndex = mod(index - 1, slides.length);
const nextIndex = mod(index + 1, slides.length);
prevSlot.innerHTML = wrapPeek(slides[prevIndex].html);
currSlot.innerHTML = wrapSlot(slides[index].html);
nextSlot.innerHTML = wrapPeek(slides[nextIndex].html);
renderDots();
}
prevBtn.addEventListener("click", () => {
index = mod(index - 1, slides.length);
update();
});
nextBtn.addEventListener("click", () => {
index = mod(index + 1, slides.length);
update();
});
update();
}
document.querySelectorAll(".carousel").forEach(initCarousel);
.carousel-row {
display: grid;
grid-template-columns: 2fr 6fr 2fr;
gap: 16px;
align-items: stretch;
}
/* slots clip their contents */
.peek, .center {
position: relative;
overflow: hidden;
min-height: 1px;
}
/* if multiple inner wrappers exist, they should overlap */
.peek .slot-inner,
.center .slot-inner {
position: absolute;
inset: 0;
width: 100%;
}
/* keep first one in-flow to preserve height */
.peek > .slot-inner:first-child,
.center > .slot-inner:first-child {
position: relative;
}
/* side peek masks */
.peek-inner { width: 300%; }
.peek-left .peek-inner { transform: translateX(-66.6667%); }
.peek-right .peek-inner { transform: translateX(0); }
.card {
background: white;
padding: 16px;
border-radius: 16px;
box-shadow: 0 0 8px rgba(0,0,0,.15);
text-align: center;
}
.card-green { color: #2b6b45; }
.card-blue { color: #3aa7c9; }
.card-yellow { color: #f1953a; }
.quote { font-size: 18px; line-height: 1.6; }
.meta::before {
content: "";
display: block;
width: 180px;
height: 4px;
background: currentColor;
margin: 16px auto;
border-radius: 2px;
}
/* I *want* to animate between slides, but this currently doesn't work well */
.slot-inner {
transition: transform 0.9s ease, opacity 0.9s ease;
}
<section class="carousel" aria-label="Testimonials">
<div class="carousel-row">
<div class="peek peek-left" data-slot="prev" aria-hidden="true"></div>
<div class="center" data-slot="current"></div>
<div class="peek peek-right" data-slot="next" aria-hidden="true"></div>
</div>
<div class="controls">
<button type="button" data-prev aria-label="Previous">‹</button>
<div class="dots" role="tablist" aria-label="Choose slide" data-dots></div>
<button type="button" data-next aria-label="Next">›</button>
</div>
<!-- Source slides (hidden) -->
<div class="slides" hidden>
<article data-title="Slide 1">
<div class="card card-green">
<p class="quote">“Generic quote text for slide one.”</p>
<div class="meta">
<div class="name">Person One</div>
<div class="sub">Category A</div>
</div>
</div>
</article>
<article data-title="Slide 2">
<div class="card card-blue">
<p class="quote">“Generic quote text for slide two.”</p>
<div class="meta">
<div class="name">Person Two</div>
<div class="sub">Category B</div>
</div>
</div>
</article>
<article data-title="Slide 3">
<div class="card card-yellow">
<p class="quote">“Generic quote text for slide three.”</p>
<div class="meta">
<div class="name">Person Three</div>
<div class="sub">Category C</div>
</div>
</div>
</article>
</div>
</section>