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.

Mia ChenProduct designer

Setup took an afternoon and the first report landed in my inbox the next morning. I have not opened the old spreadsheet since.

Tomás ReyesEngineering manager

The weekly digest is the only email my team reads end to end. Short, specific, and it names the person who unblocked you.

Aisha OkaforFounder

I wanted numbers I could defend in a board meeting. Every figure links back to the source, so nobody has to take my word for it.

Jonas LindqvistData analyst

Exports are plain CSV with sane column names. That alone saved me a script I had been maintaining for two years.

Priya NatarajanOperations lead

Support answered a Sunday question in twenty minutes with a fix, not a ticket number. That is what I pay for.

Lucas MoreauFreelance developer

The API does exactly what the docs say, and the docs are one page. I shipped the integration before lunch.

Swipe, drag, or use the arrows and dots — the rail settles on a card every time.

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:

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.

TestimonialCarousel.tsx
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:

shadcn
npx shadcn@latest add https://react-horizontal-scrolling-menu.dev/r/snap-carousel.json

Related examples

All 23 examples