Fix: Embla Carousel Not Working — Slides Not Scrolling, Autoplay Not Starting, or Thumbnails Not Syncing
Part of: React & Frontend Errors
Quick Answer
How to fix Embla Carousel issues — React setup, slide sizing, autoplay and navigation plugins, loop mode, thumbnail carousels, responsive breakpoints, and vertical scrolling.
The Problem
The carousel renders but slides don’t scroll:
import useEmblaCarousel from 'embla-carousel-react';
function Carousel() {
const [emblaRef] = useEmblaCarousel();
return (
<div ref={emblaRef}>
<div>
<div>Slide 1</div>
<div>Slide 2</div>
<div>Slide 3</div>
</div>
</div>
);
}
// All slides are visible and nothing scrollsOr autoplay doesn’t work:
import Autoplay from 'embla-carousel-autoplay';
const [emblaRef] = useEmblaCarousel({ loop: true }, [Autoplay()]);
// Carousel stays on the first slideOr navigation buttons don’t move to the next slide:
Prev/Next buttons render but clicking them does nothingWhy This Happens
Embla Carousel is a lightweight, dependency-free carousel library. It relies on CSS to define slide sizing and overflow behavior.
Embla is unusual among carousel libraries in that it owns interaction and physics but leaves layout entirely to CSS. That is what makes it small and flexible, and it is also what makes “the carousel is broken” reports so hard to triage — most of the time the CSS is wrong, not the JavaScript. The library measures the DOM after layout, so anything that changes layout after mount (image loads without dimensions, font swaps, conditional content) silently invalidates Embla’s measurements. The slides still appear, but scroll snaps land in the wrong place and the user perceives a “broken” carousel even though no error has thrown.
- Slide sizing is controlled by CSS, not JavaScript — Embla doesn’t set slide widths. Without proper CSS (
flex: 0 0 100%on slides andoverflow: hiddenon the viewport), all slides are visible simultaneously and there’s nothing to scroll. - The DOM structure must be exact — Embla requires a viewport (ref target) → container (flex wrapper) → slides (flex items) hierarchy. Missing the container
divbetween the viewport and slides breaks the scroll calculation. - Autoplay requires the plugin package —
embla-carousel-autoplayis a separate npm package. Importing it without installing causes a module error. The plugin must also be passed as the second argument touseEmblaCarousel. - Navigation needs the Embla API — prev/next buttons must call
emblaApi.scrollPrev()andemblaApi.scrollNext(). The API is available from the second return value ofuseEmblaCarousel.
A second class of failure is the boundary between Embla and SSR. In Next.js App Router or Remix, the carousel viewport renders on the server with overflow: hidden but no width measurements; on hydration, Embla measures and may not get the dimensions it expects if hydration races image decoding. This is why some users report “carousel works on refresh but not on first load.” The fix is not to disable SSR — it is to ensure your slide content has stable intrinsic dimensions (<img> with width and height, font preloading) so layout is final before Embla measures.
Fix 1: Basic Carousel with Correct CSS
npm install embla-carousel-react'use client';
import useEmblaCarousel from 'embla-carousel-react';
import { useCallback } from 'react';
function Carousel({ slides }: { slides: { id: string; content: React.ReactNode }[] }) {
const [emblaRef, emblaApi] = useEmblaCarousel({
align: 'start',
// loop: true, // Enable infinite scroll
});
const scrollPrev = useCallback(() => emblaApi?.scrollPrev(), [emblaApi]);
const scrollNext = useCallback(() => emblaApi?.scrollNext(), [emblaApi]);
return (
<div className="relative">
{/* Viewport — overflow hidden */}
<div className="overflow-hidden" ref={emblaRef}>
{/* Container — flex row */}
<div className="flex">
{/* Slides — flex-shrink: 0, set width */}
{slides.map(slide => (
<div
key={slide.id}
className="flex-[0_0_100%] min-w-0 px-2"
// flex-[0_0_100%] = flex: 0 0 100% → one slide per view
// For 3 slides per view: flex-[0_0_33.333%]
// For 2 slides per view: flex-[0_0_50%]
>
{slide.content}
</div>
))}
</div>
</div>
{/* Navigation buttons */}
<button
onClick={scrollPrev}
className="absolute left-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white shadow-md flex items-center justify-center hover:bg-gray-50"
aria-label="Previous slide"
>
←
</button>
<button
onClick={scrollNext}
className="absolute right-2 top-1/2 -translate-y-1/2 w-10 h-10 rounded-full bg-white shadow-md flex items-center justify-center hover:bg-gray-50"
aria-label="Next slide"
>
→
</button>
</div>
);
}
// Usage
<Carousel slides={[
{ id: '1', content: <img src="/image1.jpg" className="w-full rounded-lg" /> },
{ id: '2', content: <img src="/image2.jpg" className="w-full rounded-lg" /> },
{ id: '3', content: <img src="/image3.jpg" className="w-full rounded-lg" /> },
]} />Fix 2: Dot Indicators
'use client';
import useEmblaCarousel from 'embla-carousel-react';
import { useCallback, useEffect, useState } from 'react';
function CarouselWithDots({ children }: { children: React.ReactNode[] }) {
const [emblaRef, emblaApi] = useEmblaCarousel();
const [selectedIndex, setSelectedIndex] = useState(0);
const [scrollSnaps, setScrollSnaps] = useState<number[]>([]);
const onSelect = useCallback(() => {
if (!emblaApi) return;
setSelectedIndex(emblaApi.selectedScrollSnap());
}, [emblaApi]);
useEffect(() => {
if (!emblaApi) return;
setScrollSnaps(emblaApi.scrollSnapList());
emblaApi.on('select', onSelect);
onSelect();
}, [emblaApi, onSelect]);
return (
<div>
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{children.map((child, i) => (
<div key={i} className="flex-[0_0_100%] min-w-0">
{child}
</div>
))}
</div>
</div>
{/* Dot indicators */}
<div className="flex justify-center gap-2 mt-4">
{scrollSnaps.map((_, index) => (
<button
key={index}
onClick={() => emblaApi?.scrollTo(index)}
className={`w-3 h-3 rounded-full transition-colors ${
index === selectedIndex ? 'bg-blue-500' : 'bg-gray-300'
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
</div>
);
}Fix 3: Autoplay Plugin
npm install embla-carousel-autoplay'use client';
import useEmblaCarousel from 'embla-carousel-react';
import Autoplay from 'embla-carousel-autoplay';
import { useCallback } from 'react';
function AutoplayCarousel({ slides }: { slides: React.ReactNode[] }) {
const [emblaRef, emblaApi] = useEmblaCarousel(
{ loop: true },
[
Autoplay({
delay: 4000, // 4 seconds between slides
stopOnInteraction: true, // Stop when user interacts
stopOnMouseEnter: true, // Pause on hover
playOnInit: true, // Start automatically
}),
],
);
// Manually control autoplay
const toggleAutoplay = useCallback(() => {
const autoplay = emblaApi?.plugins().autoplay;
if (!autoplay) return;
if (autoplay.isPlaying()) {
autoplay.stop();
} else {
autoplay.play();
}
}, [emblaApi]);
return (
<div>
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{slides.map((slide, i) => (
<div key={i} className="flex-[0_0_100%] min-w-0">
{slide}
</div>
))}
</div>
</div>
<button onClick={toggleAutoplay}>Toggle Autoplay</button>
</div>
);
}Fix 4: Responsive Slide Sizes
// Different number of slides per view at different breakpoints
// Use CSS for this — Embla reads slide sizes from CSS
function ResponsiveCarousel({ items }: { items: Item[] }) {
const [emblaRef] = useEmblaCarousel({ align: 'start' });
return (
<div className="overflow-hidden" ref={emblaRef}>
<div className="flex">
{items.map(item => (
<div
key={item.id}
className="
flex-[0_0_100%]
sm:flex-[0_0_50%]
lg:flex-[0_0_33.333%]
min-w-0 px-2
"
// 1 slide on mobile, 2 on tablet, 3 on desktop
>
<div className="bg-white rounded-lg shadow p-4">
<h3>{item.title}</h3>
<p>{item.description}</p>
</div>
</div>
))}
</div>
</div>
);
}
// With gap between slides
function GappedCarousel() {
const [emblaRef] = useEmblaCarousel({ align: 'start' });
return (
<div className="overflow-hidden" ref={emblaRef}>
{/* Negative margin to compensate for padding */}
<div className="flex -ml-4">
{items.map(item => (
<div key={item.id} className="flex-[0_0_33.333%] min-w-0 pl-4">
{/* pl-4 creates the gap */}
<div className="bg-gray-100 rounded-lg p-4">
{item.content}
</div>
</div>
))}
</div>
</div>
);
}Fix 5: Thumbnail Navigation
'use client';
import useEmblaCarousel from 'embla-carousel-react';
import { useCallback, useEffect, useState } from 'react';
function ThumbnailCarousel({ images }: { images: string[] }) {
// Main carousel
const [mainRef, mainApi] = useEmblaCarousel();
// Thumbnail carousel
const [thumbRef, thumbApi] = useEmblaCarousel({
containScroll: 'keepSnaps',
dragFree: true,
});
const [selectedIndex, setSelectedIndex] = useState(0);
// Sync main → thumbs
const onSelect = useCallback(() => {
if (!mainApi || !thumbApi) return;
const index = mainApi.selectedScrollSnap();
setSelectedIndex(index);
thumbApi.scrollTo(index);
}, [mainApi, thumbApi]);
useEffect(() => {
if (!mainApi) return;
mainApi.on('select', onSelect);
onSelect();
}, [mainApi, onSelect]);
// Click thumb → scroll main
const onThumbClick = useCallback(
(index: number) => {
mainApi?.scrollTo(index);
},
[mainApi],
);
return (
<div>
{/* Main carousel */}
<div className="overflow-hidden mb-2" ref={mainRef}>
<div className="flex">
{images.map((src, i) => (
<div key={i} className="flex-[0_0_100%] min-w-0">
<img src={src} className="w-full h-96 object-cover rounded-lg" />
</div>
))}
</div>
</div>
{/* Thumbnail carousel */}
<div className="overflow-hidden" ref={thumbRef}>
<div className="flex gap-2">
{images.map((src, i) => (
<button
key={i}
onClick={() => onThumbClick(i)}
className={`flex-[0_0_80px] min-w-0 rounded-md overflow-hidden border-2 transition-colors ${
i === selectedIndex ? 'border-blue-500' : 'border-transparent'
}`}
>
<img src={src} className="w-full h-16 object-cover" />
</button>
))}
</div>
</div>
</div>
);
}Fix 6: Vertical Carousel
function VerticalCarousel({ items }: { items: React.ReactNode[] }) {
const [emblaRef, emblaApi] = useEmblaCarousel({
axis: 'y', // Vertical scrolling
align: 'start',
});
return (
<div
className="overflow-hidden h-[400px]" // Fixed height required for vertical
ref={emblaRef}
>
<div className="flex flex-col h-full">
{items.map((item, i) => (
<div
key={i}
className="flex-[0_0_100%] min-h-0" // min-h-0 instead of min-w-0
>
{item}
</div>
))}
</div>
</div>
);
}Production Incident: When a Broken Carousel Hides Half the Catalog
The homepage of a content-heavy site relies on a featured carousel to surface new articles, products, or videos. On the deploy that follows a font system migration, the carousel stops snapping correctly. Slides drift mid-scroll; the prev/next buttons appear to overshoot or undershoot; on mobile the third slide is visible at rest with the first cut off. Functionally the carousel still scrolls, but the user perceives it as broken and stops interacting after the first slide.
The metric that catches this is engagement, not error rate. Clicks on slides 2-5 drop by 40%. Articles featured in carousel slots get half the traffic they got the week before. Editorial teams notice that “our cover story isn’t doing well this week” without realizing the surface is broken, not the story. By the time someone correlates the engagement dip with the font deploy, a week of featured content has been served to a broken surface.
The root cause was that the font swap changed line-height after measurement. Embla measured the slide container at hydration when the system font was still rendering; after the custom font loaded a few hundred milliseconds later, slide heights shifted by 12px. Embla’s snap positions were locked to the pre-swap measurements. No JavaScript error. No console warning. Just a quietly degraded carousel.
Three monitoring habits help. First, run visual regression tests (Percy, Chromatic, Playwright screenshots) on the carousel’s initial and post-scroll states; pixel diffs catch what error logs miss. Second, instrument scroll-snap behavior with a custom metric: log when emblaApi.on('select') fires versus when the user expects it to. Drift between expected and actual snaps is the leading indicator. Third, treat font-loading and image-loading as hydration-blocking for the carousel: preload critical assets in the document head, and call emblaApi.reInit() in a document.fonts.ready callback to re-measure after the swap.
The fix during the incident is emblaApi.reInit() on a ResizeObserver callback. The fix after the incident is to add a visual regression test that would have failed on the deploy.
Still Not Working?
All slides visible, nothing scrolls — the CSS is wrong. The viewport needs overflow: hidden. The container needs display: flex. Each slide needs flex: 0 0 <width> (e.g., flex: 0 0 100% for one slide per view). Also add min-width: 0 on slides to prevent flexbox overflow.
Autoplay doesn’t start — verify embla-carousel-autoplay is installed (separate package). The plugin must be passed in an array as the second argument: useEmblaCarousel({ loop: true }, [Autoplay()]). Without loop: true, autoplay stops at the last slide.
Navigation buttons do nothing — emblaApi is undefined on first render. Use useCallback with emblaApi in the dependency array. Also check that emblaApi is the second return value: const [emblaRef, emblaApi] = useEmblaCarousel().
Carousel jumps or has wrong spacing — check for CSS that adds unexpected width or padding to slides. Embla calculates positions from the actual DOM dimensions. If CSS changes after mount (e.g., font loading, images loading without dimensions), call emblaApi.reInit() to recalculate.
Carousel measurements drift after font or image swap — Embla measures once at mount. If layout shifts later, snap positions desynchronize from rendered slides. Wrap the carousel in a ResizeObserver and call emblaApi.reInit() on every resize event. Also trigger reInit from document.fonts.ready and from each image’s onLoad handler.
Autoplay pauses indefinitely after the user returns to the tab — the Page Visibility API pauses autoplay when the tab is hidden, but some plugin versions don’t resume automatically. Listen for visibilitychange on document and call emblaApi.plugins().autoplay.play() when the page becomes visible. Test by switching tabs for a minute and switching back.
Loop mode skips slides on first wrap — loop: true requires at least slidesToScroll * 2 + 1 slides to wrap cleanly. With three slides and slidesToScroll: 2, the wrap math fails and Embla drops to non-loop behavior silently. Either lower slidesToScroll, raise the slide count, or accept a non-looping carousel.
For related UI component issues, see Fix: Framer Motion Not Working, Fix: dnd-kit Not Working, Fix: GSAP Not Working, and Fix: Radix UI Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: AutoAnimate Not Working — Transitions Not Playing, List Items Not Animating, or React State Changes Ignored
How to fix @formkit/auto-animate issues — parent ref setup, React useAutoAnimate hook, Vue directive, animation customization, disabling for specific elements, and framework integration.
Fix: Blurhash Not Working — Placeholder Not Rendering, Encoding Failing, or Colors Wrong
How to fix Blurhash image placeholder issues — encoding with Sharp, decoding in React, canvas rendering, Next.js image placeholders, CSS blur fallback, and performance optimization.
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: Lottie Not Working — Animation Not Playing, File Not Loading, or React Component Blank
How to fix Lottie animation issues — lottie-react and lottie-web setup, JSON animation loading, playback control, interactivity, lazy loading, and performance optimization.