---
title: "React testimonial carousel on native scroll snap"
description: "Testimonial carousel in React without a slider library: CSS scroll-snap centers each card, a scroll-driven animation fans out the rest. Dots, arrows, drag, source."
canonical: "https://react-horizontal-scrolling-menu.dev/testimonial-carousel"
image: "https://react-horizontal-scrolling-menu.dev/og.png"
---

# 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.

M**Mia Chen**Product designer

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

T**Tomás Reyes**Engineering manager

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

A**Aisha Okafor**Founder

“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.”

J**Jonas Lindqvist**Data analyst

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

P**Priya Natarajan**Operations lead

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

L**Lucas Moreau**Freelance 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.

[Edit this example live in Storybook](https://asmyshlyaev177.github.io/react-horizontal-scrolling-menu/?path=/story/examples-scrollsnap--scroll-snap)

## 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 mandatory` on the rail and `scroll-snap-align: center` on 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: `onScroll` finds the one nearest the rail’s center, and `scrollToItem(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](https://react-horizontal-scrolling-menu.dev/examples/mouse-drag.md) as written would stutter from card to card. The [scroll-snap example](https://react-horizontal-scrolling-menu.dev/examples/scroll-snap.md) 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](https://react-horizontal-scrolling-menu.dev/examples/autoplay.md) is a timer calling the same `scrollToItem`, paused on hover, focus and reduced motion. An [infinite loop](https://react-horizontal-scrolling-menu.dev/examples/infinite-loop.md) 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](https://react-horizontal-scrolling-menu.dev/r/snap-carousel.json) 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

-   [Scroll snap carouselCards snap to the center and fan out in CSS — no carousel engine.](https://react-horizontal-scrolling-menu.dev/examples/scroll-snap.md)
-   [AutoplayA self-advancing loop with accessible pause behavior.](https://react-horizontal-scrolling-menu.dev/examples/autoplay.md)
-   [Infinite loopSeamless looping from the public API — no library changes.](https://react-horizontal-scrolling-menu.dev/examples/infinite-loop.md)

[All 23 examples](https://react-horizontal-scrolling-menu.dev/examples.md)

---

More examples: <https://react-horizontal-scrolling-menu.dev/examples.md>
Library summary for LLMs: <https://react-horizontal-scrolling-menu.dev/llms.txt>
