“Setup took an afternoon and the first report landed in my inbox the next morning. I have not opened the old spreadsheet since.”
A testimonial carousel in React, on native scroll snap
The reviews section on every marketing site: one card in the middle, its neighbours tilted behind it, dots underneath, a swipe or a click to the next one. Slider libraries sell exactly this. The browser already has both halves — scroll-snap-type for the landing and a scroll-driven animation for the tilt — and this library adds the part that is neither: knowing which card is current.
Swipe, drag, or use the arrows and dots — the rail settles on a card every time.
Edit this example live in Storybook
What a slider library is really for here
Testimonial carousels are the canonical Swiper install: three visible cards, the middle one flat, pagination bullets, autoplay. Every one of those is either CSS or a few lines on this library’s API:
- Landing on a card is
scroll-snap-type: x mandatoryon the rail andscroll-snap-align: centeron the items. Touch, wheel and keyboard scrolling stay native and decelerate onto a card by themselves. - The tilt is a scroll-driven animation on each card, so the angle follows the scroll position pixel for pixel with nothing measured in JavaScript. Chromium and Safari 26 draw it; Firefox still keeps it behind a flag and shows flat cards, and the rule is guarded with
@supports. - Dots and arrows need to know the current card:
onScrollfinds the one nearest the rail’s center, andscrollToItem(el, 'smooth', 'center')steps to a neighbour or jumps to a dot’s card, landing on a snap point by construction.
The one thing to get right: drag
A mandatory snap container snaps every programmatic write to scrollLeft, so the mouse-drag recipe as written would stutter from card to card. The scroll-snap example shows the fix: switch snapping off for the gesture, glide to the closest card with scrollToItem on release, and hand snapping back to CSS once that glide has settled — re-enabling it earlier jumps instead of gliding. Touch needs none of this; the rail is a real scroll container.
Autoplay, loop, and what stays yours
Autoplay is a timer calling the same scrollToItem, paused on hover, focus and reduced motion. An infinite loop composes too: its teleport moves by a whole loop length, which is a whole number of snap points. The cards are your components — an avatar, a rating, a quote, a logo — and the geometry is three custom properties: card width, gap and tilt.
The pattern, minimal
Snap and tilt are the stylesheet; the arrows and dots are one measurement and one scrollToItem. The demo above is this plus the cards.
function TestimonialCarousel({ reviews }: { reviews: Review[] }) {
const [active, setActive] = React.useState(0);
return (
<ScrollMenu
scrollContainerClassName="snap-rail"
itemClassName="snap-slot"
onScroll={(api) => setActive(nearestIndex(api, reviews))}
LeftArrow={<Arrow id={reviews[active - 1]?.id} />}
RightArrow={<Arrow id={reviews[active + 1]?.id} />}
>
{reviews.map((review) => (
<ReviewCard itemId={review.id} key={review.id} {...review} />
))}
</ScrollMenu>
);
}
// The card whose center is closest to the rail's center.
function nearestIndex(api: publicApiType, reviews: Review[]) {
const rail = api.scrollContainer.current!;
const middle = rail.scrollLeft + rail.clientWidth / 2;
const distances = reviews.map(({ id }) => {
const slot = api.getItemElementById(id) as HTMLElement;
return Math.abs(slot.offsetLeft + slot.offsetWidth / 2 - middle);
});
return distances.indexOf(Math.min(...distances));
}
// Arrows step one card: the neighbour of the centered one, centered.
function Arrow({ id }: { id?: string }) {
const api = React.useContext<publicApiType>(VisibilityContext);
const step = () => {
const el = id && api.getItemElementById(id);
if (el) api.scrollToItem(el, 'smooth', 'center');
};
return <button disabled={!id} onClick={step}>→</button>;
}
/* .snap-rail — the browser lands every swipe on a card:
scroll-snap-type: x mandatory;
padding-inline: calc(50% - var(--card) / 2);
.snap-slot — scroll-snap-align: center;
The fan is the snap.css panel on the homepage. */Or install it as a shadcn component
The snap-carousel registry item ships the whole pattern — snap, fan, arrows, dots and a drag that releases onto the closest card — as a Tailwind-styled component in your components/ui/. Bring your own cards:
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/snap-carousel.json