# Embla Carousel React: Accessible Slides, No Layout Shift

> Build an embla carousel react component for photos, listings, testimonials and onboarding, with keyboard controls, pausable autoplay and no layout shift.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 19, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 9 min
- Canonical: https://wingo-ui.com/blog/embla-carousel-react

## TL;DR

Embla gives React a hook, useEmblaCarousel, that handles drag physics and snapping, while you supply the markup, the slide widths in CSS and every control. A production carousel adds arrows, dots, keyboard keys, inert off-screen slides, an autoplay pause button and server-rendered slide widths so nothing jumps on load. The Wingo UI Carousel wraps Embla with all of that as props, and the Onboarding Carousel builds first-run slides on top of it.

You need a photo gallery, a rail of product cards, testimonials or welcome slides, and you picked Embla because it drags well on a phone. The embla carousel react hook gets you drag physics and snapping with a few lines of markup. Then the real work starts: arrows, dots, keyboard keys, autoplay that people can stop, slides that do not jump when the page hydrates, and screen readers that hear "3 of 8" instead of a pile of images. This guide covers what Embla gives you, what you still have to build, and how the Wingo UI [Carousel](https://wingo-ui.com/components/carousel) handles each part.

## What does Embla Carousel do in React?

It moves the track and you do everything else. Embla is an MIT-licensed, [dependency-free carousel engine](https://github.com/davidjerleke/embla-carousel). This guide uses Embla 8, the stable line (8.6.0 as of October 2026, [v8 docs](https://www.embla-carousel.com/docs/v8/api/methods)). The React package exposes one hook, `useEmblaCarousel`, which returns a ref for the viewport and an api for `scrollNext`, `scrollTo`, `selectedScrollSnap` and events. The markup is three nested elements, and the slide widths are yours to set in CSS. Here is the minimal react carousel tailwind version:

```tsx
"use client";

import useEmblaCarousel from "embla-carousel-react";

export function BareCarousel({ photos }: { photos: string[] }) {
  const [emblaRef] = useEmblaCarousel({ loop: false });

  return (
    <div ref={emblaRef} className="overflow-hidden">
      <div className="flex">
        {photos.map((src) => (
          <div key={src} className="min-w-0 shrink-0 grow-0 basis-full">
            <img src={src} alt="" draggable={false} className="aspect-[4/3] w-full object-cover" />
          </div>
        ))}
      </div>
    </div>
  );
}
```

That drags and snaps. It has no arrows, dots, keyboard support or slide names, and to show the current slide you subscribe to the `select` event and read `selectedScrollSnap()`. Embla leaves those out on purpose so it stays small, which is why most embla carousel examples you find are mostly control code. One version note: the Embla docs site now opens on the 9.0 release candidate, which renames these methods (`goToNext`, `goTo`, `selectedSnap`), so check which version a snippet targets before you copy it.

## How do Embla, shadcn and Wingo UI carousels compare?

All three use the same engine, so drag and snapping feel the same; they differ in how much of the control layer ships. Facts are from each project's public docs as of October 2026.

| Option | What you get | Slide widths | Dots, thumbnails | Autoplay |
|---|---|---|---|---|
| [embla-carousel-react](https://www.embla-carousel.com/docs/v8/api/methods) | The hook and the api | Your CSS | Build them from the api | [embla-carousel-autoplay](https://www.embla-carousel.com/docs/v8/plugins/autoplay) plugin, no pause button |
| [shadcn/ui Carousel](https://ui.shadcn.com/docs/components/carousel) | Free source parts: `Carousel`, `CarouselContent`, `CarouselItem`, `CarouselPrevious`, `CarouselNext` | `basis-*` classes on each item | Not in the docs; the api example shows a "Slide X of Y" counter | The Embla plugin through the `plugins` prop |
| [Wingo UI Carousel](https://wingo-ui.com/components/carousel) | Pro source component on Embla 8 | `slidesPerView`, per container width | `indicator` (dots, bars, progress, counter) and `thumbnails` | Built in, with a pause button that is always shown |

The shadcn carousel is the better pick if you want free, minimal parts and plan to write your own dots and autoplay controls. Raw Embla is right when the design is unusual enough that any wrapper gets in the way. The rest of this guide covers the Wingo UI version.

## What props does a React carousel need?

Usually none beyond its slides. The zero-prop Carousel shows one slide at a time with dots below, arrows on a mouse or trackpad, and a peek of the next slide on a phone. Install it, then pass the slides as children:

```bash
npx wingo-ui@latest add carousel
```

```tsx
import { Carousel } from "@/components/ui/carousel";

const photos = ["/photos/living-room.jpg", "/photos/kitchen.jpg", "/photos/bedroom.jpg"];

export function ListingPhotos() {
  return (
    <Carousel label="Apartment photos" indicator="counter" indicatorPosition="overlay" thumbnails={photos}>
      {photos.map((src, i) => (
        <img key={src} src={src} alt={`Photo ${i + 1}`} draggable={false} className="aspect-[4/3] w-full object-cover" />
      ))}
    </Carousel>
  );
}
```

Each child is wrapped in a slide automatically. Set `draggable={false}` on images so the browser's own image drag does not fight the swipe. The props you will reach for next are `slidesPerView`, `gap` (`none`, `sm`, `md`, `lg`), `align` (`start` or `center`), `loop`, `arrows` (`auto`, `always`, `never`), `arrowPosition` (`inside`, `outside`, `header`) and `indicator`. Anything Embla supports that has no prop, such as `slidesToScroll` or `duration`, goes through `opts`, and `setApi` hands you the Embla api.

Drag the photos, then click into the carousel and use the Left and Right keys. On a phone, notice the next listing peeking from the edge.

> Live demo (Carousel): Swipe or drag the listings, click a dot to jump, or focus the carousel and press Left, Right, Home and End. Try it at [Carousel](https://wingo-ui.com/components/carousel) and install it with `npx wingo-ui@latest add carousel`.

## How do I build listing rails and testimonials?

Change `slidesPerView` and two or three layout props. A number shows that many slides; a fraction peeks (1.15 shows 15% of the next one). An object sets it per carousel width, so a rail in a narrow sidebar shows fewer cards than the same rail across the page:

```tsx
import { Carousel } from "@/components/ui/carousel";
import { Card } from "@/components/ui/card";

type Listing = { id: string; photo: string; title: string; city: string; price: string };

export function RecommendedRail({ listings }: { listings: Listing[] }) {
  return (
    <Carousel
      title="Recommended for you"
      label="Recommended listings"
      slidesPerView={{ base: 1.15, md: 2.5, lg: 3.5 }}
      arrowPosition="header"
      indicator="none"
    >
      {listings.map((item) => (
        <Card key={item.id} variant="outline" size="sm" className="h-full" media={item.photo} title={item.title} description={`${item.city} · ${item.price}`} />
      ))}
    </Carousel>
  );
}
```

The breakpoints are container queries on the carousel itself (`sm` from 640px, `md` from 768px, `lg` from 1024px). For testimonials, center the selected slide and let the others shrink:

```tsx
<Carousel label="Customer reviews" effect="scale" align="center" loop slidesPerView={{ base: 1.2, md: 1.8 }} peek={false} radius="lg">
  {reviews.map((review) => (
    <figure key={review.name} className="flex h-full flex-col gap-4 bg-card p-5">
      <blockquote className="flex-1 text-[15px] leading-relaxed">{review.quote}</blockquote>
      <figcaption className="text-sm font-medium">{review.name}</figcaption>
    </figure>
  ))}
</Carousel>
```

`effect="scale"` shrinks slides away from the snap to 0.9 and dims them. It reads a CSS variable written per scroll event instead of React state, so a drag does not re-render the slides. For a rail of chips that should glide without snapping, use `slidesPerView="auto"` with `dragFree`.

## How do I stop a carousel from shifting layout on load?

Put the slide widths in CSS that the server renders. Embla [measures the computed size of your slides](https://www.embla-carousel.com/docs/v8/guides/slide-sizes) and never sets widths itself, so the classic layout shift comes from code that measures the window in an effect and then sizes the slides. On the first paint every slide is full width, then they snap to thirds.

The Carousel turns `slidesPerView` into CSS custom properties per container breakpoint, so the server HTML already has the final widths and the phone peek. The dot count is estimated from the slide count before Embla has measured, so the indicator does not appear late either. Two things are still on you:

1. **Give media a fixed shape.** Use an `aspect-*` class or width and height attributes on every image. An image without dimensions changes the slide height when it loads, and Embla has to measure again.
2. **Load the first slides eagerly.** Mark the first one or two images `loading="eager"` and the rest `lazy`.

## How do I make an Embla carousel accessible?

Follow the [W3C carousel pattern](https://www.w3.org/WAI/ARIA/apg/patterns/carousel/): a labeled region with `aria-roledescription="carousel"`, slides as groups with a name, previous and next buttons, and a live region that is quiet while autoplay runs. The Carousel renders that structure. Each slide gets role group and a name like "3 of 8", and the slide container is `aria-live="polite"` while autoplay is paused and `off` while it runs.

Keyboard support goes beyond the pattern's minimum. The viewport itself is focusable, so Left and Right move one slide (Up and Down when `orientation="vertical"`), and Home and End jump to the ends. Inputs inside slides keep their own arrow keys. Dots and thumbnails are one Tab stop each with focus following the selection, and slides out of view are `inert`, so Tab never lands on a link you cannot see. When an arrow turns disabled at the end of the track, its focus moves to the other arrow instead of dropping to the page.

Name the carousel after its content with `label`. The default is "Carousel", which a screen reader announces as "Carousel, carousel", and the W3C pattern advises against the word carousel in the label. Every string, including the "3 of 8" and "Go to slide 3" names, lives in `labels` and merges over `DEFAULT_CAROUSEL_LABELS`, so translating does not mean forking.

## How do I add autoplay people can stop?

Set `autoplay` and give it a pause button, which the Carousel always renders while autoplay is on. WCAG 2.2 success criterion [2.2.2 Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) (level A) requires a way to pause motion that starts by itself and lasts more than five seconds. The official embla carousel autoplay plugin stops after a drag by default (`stopOnInteraction: true`) and exposes `play()` and `stop()`, so the visible control is yours to build.

```tsx
<Carousel label="Offers" autoplay loop interval={4500} indicator="bars" indicatorPosition="overlay" peek={false} radius="lg">
  {offers.map((offer, i) => (
    <div key={offer.title} className="relative aspect-[4/5] w-full @min-[640px]:aspect-[16/8]">
      <img src={offer.photo} alt="" draggable={false} loading={i === 0 ? "eager" : "lazy"} className="absolute inset-0 size-full object-cover" />
      <p className="absolute inset-x-5 bottom-14 text-[22px] font-bold text-white">{offer.title}</p>
    </div>
  ))}
</Carousel>
```

The timer is the component's own, without the plugin. It pauses while a mouse is over the carousel (`pauseOnHover`), while keyboard focus is inside, while dragging, when the tab is hidden and when the carousel is scrolled off screen. Without `loop` it stops on the last slide. `stopOnInteraction` decides whether a drag or an arrow restarts the timer (the default) or stops autoplay until someone presses play. Under reduced motion, autoplay starts paused and arrows jump instead of sliding; the [prefers-reduced-motion guide](https://wingo-ui.com/blog/prefers-reduced-motion-react) explains how that is read without a hydration mismatch.

Turn on autoplay below and watch the story bars fill, then hover or tab into the carousel to pause it.

> Live demo (Carousel): Story bars fill as autoplay runs. Hover or focus the carousel to pause, or press the pause button to stop it. Try it at [Carousel](https://wingo-ui.com/components/carousel) and install it with `npx wingo-ui@latest add carousel`.

## How does the carousel behave on a phone?

It swipes and gets out of the way of vertical scrolling. Below 640px of carousel width, a whole-number `slidesPerView` shows 12% of the next slide so people can tell there is more (`peek={false}` turns it off). Arrows hide on touch with `arrows="auto"`, dots get 44px tap areas, and the track sets `touch-action: pan-y` so a vertical swipe still scrolls the page. On a laptop, a two-finger trackpad swipe sideways moves one slide per swipe and does not trigger the browser's back gesture; set `wheel={false}` if the carousel sits inside something that scrolls sideways.

## How do I build onboarding slides with Embla?

Use the [Onboarding Carousel](https://wingo-ui.com/components/onboarding-carousel), which is built on the Carousel. It adds Skip, Next that turns into Get started on the last slide, a background color per slide that cross-fades while you swipe, and `persistKey`, which remembers a finished tour in localStorage so it does not show again.

```bash
npx wingo-ui@latest add onboarding-carousel
```

```tsx
"use client";

import { useRouter } from "next/navigation";
import { OnboardingCarousel, type OnboardingSlide } from "@/components/ui/onboarding-carousel";

const slides: OnboardingSlide[] = [
  { id: "clients", title: "Every client in one place", description: "Companies, contacts and balances on your phone.", image: "/onboarding/clients.svg", background: "var(--info-soft)" },
  { id: "invoices", title: "Invoices in 30 seconds", description: "Issue an invoice and we email it to your client.", image: "/onboarding/invoices.svg", background: "var(--success-soft)" },
  { id: "offline", title: "Works offline too", description: "Everything syncs when you are back online.", image: "/onboarding/offline.svg" },
];

export default function Welcome() {
  const router = useRouter();
  return <OnboardingCarousel layout="screen" slides={slides} persistKey="onboarding-v1" showBack onComplete={() => router.replace("/home")} />;
}
```

`layout="screen"` fills the viewport with safe-area padding, `card` makes a 420 by 640px surface, and the `modal` prop opens it in a dialog that is a full screen on a phone and a centered card from 768px. Each settled slide is announced, and Escape in the modal counts as Skip.

> Live demo (Onboarding Carousel): Swipe or press Continue through the slides. Skip jumps to the last one, and Get started ends the tour. Try it at [Onboarding Carousel](https://wingo-ui.com/components/onboarding-carousel) and install it with `npx wingo-ui@latest add onboarding-carousel`.

For a custom footer inside a plain carousel, the compound parts (`CarouselRoot`, `CarouselViewport`, `CarouselSlide`, `CarouselDots`) and the `useCarousel` hook give you `selectedIndex`, `snapCount`, `scrollNext` and `scrollTo`, so a Continue button is a few lines. The [React onboarding flow tutorial](https://wingo-ui.com/blog/react-onboarding-flow) puts these slides together with permission asks and an install prompt.

## What are the common Embla carousel pitfalls?

- **Images that drag.** Without `draggable={false}`, a mouse drag on a photo picks up the image instead of moving the track.
- **Links in hidden slides.** Tab walks into off-screen slides unless they are inert, and a keyboard user lands on a link they cannot see.
- **Autoplay without a pause button.** Motion that runs longer than five seconds without one fails WCAG 2.2.2, and it moves the slide away from someone halfway through reading it.
- **A carousel for content people must see.** Anything on slide four is hidden until someone swipes three times. Put the one thing that matters in a static card, and use carousels for browsing, as in the [marketplace template](https://wingo-ui.com/blog/react-marketplace-template).

## How do I add it to my project?

Run `npx wingo-ui@latest add carousel` (or `onboarding-carousel`). The CLI copies the source into `components/ui`, adds the [Button](https://wingo-ui.com/components/button) and the shared hooks and helpers it imports, and installs `embla-carousel-react`, `motion` and `lucide-react`. Both carousels are Pro components ([pricing](https://wingo-ui.com/pricing)). More single-component deep dives are in the [component guides category](https://wingo-ui.com/blog/category/components), and the [React form components guide](https://wingo-ui.com/blog/react-form-components-guide) applies the same keyboard, touch and screen reader rules to every form control.

## Components in this post

- [Carousel](https://wingo-ui.com/components/carousel) (Pro): A swipeable React carousel on Embla for photos, cards, testimonials and onboarding, with peek, arrows, dots, thumbnails and autoplay. Install: `npx wingo-ui@latest add carousel`
- [Onboarding Carousel](https://wingo-ui.com/components/onboarding-carousel) (Pro): A React onboarding carousel of first-run slides for an app or PWA: swipe or tap Next, follow the dots, Skip any time, then Get started. Install: `npx wingo-ui@latest add onboarding-carousel`

## FAQ

### What is Embla Carousel in React?

Embla Carousel is an MIT-licensed, dependency-free carousel engine. The embla-carousel-react package exposes a useEmblaCarousel hook that returns a ref for the viewport and an api for scrolling, events and state; you write the markup, the CSS and the controls yourself.

### How do I add autoplay to an Embla carousel?

With raw Embla you install the official embla-carousel-autoplay plugin and pass it to useEmblaCarousel; in version 8 its documented default delay is 4000 ms and it stops after the user drags (stopOnInteraction defaults to true). You still need to render your own pause button, because WCAG 2.2.2 asks for a way to stop motion that runs longer than five seconds.

### Is the shadcn carousel built on Embla?

Yes. The shadcn/ui Carousel wraps Embla with Carousel, CarouselContent, CarouselItem, CarouselPrevious and CarouselNext, sets slide widths with basis classes, and takes Embla options and plugins as props. Its docs do not ship dots, thumbnails or an autoplay pause button, so you build those from the api.

### How do I stop an Embla carousel from shifting layout on load?

Set slide widths in CSS so the server HTML already has the right size, and give every image a fixed aspect ratio. Embla measures the slides after mount and never sets widths itself, so anything sized by JavaScript after hydration will jump.

### Is the Wingo UI Carousel free?

No. The Carousel and the Onboarding Carousel are Pro components. The Button and Card used in the examples are free.

---

Source: https://wingo-ui.com/blog/embla-carousel-react
