Embla Carousel Setup

Used in this guide:
Next.js 15.3.3
Embla Carousel 8.6.0

Introduction

Embla Carousel is a lightweight, customizable slider library that works seamlessly with React and Next.js. Whether you're building a product showcase, image gallery, or interactive section, Embla provides full control with a clean, low-level API. In this tutorial, we'll walk through a step-by-step setup of Embla Carousel in a Next.js 15 project — starting with a basic setup and gradually adding features like separated slide components, arrow controls, and dot navigation.

Install Embla Carousel Library

npm install embla-carousel-react

Setup 1: Basic

This is the most minimal Embla Carousel setup. We use useEmblaCarousel() to create a scrollable container. The emblaRef is attached to the wrapper element, and basic slides are placed inside. This is a great starting point to confirm that Embla is working correctly in your environment.

app/components/Carousel.tsx
'use client'
import useEmblaCarousel from "embla-carousel-react";
 
const Carousel = () => {
 
    const [emblaRef] = useEmblaCarousel({ loop: true });
 
    return (
        <div>
            <h2 className="mb-4">Basic Setup</h2>
 
            {/* Embla Scope */}
            <div ref={emblaRef} className="embla">
                <div className="embla__container h-[450px]">
                    <div className="embla__slide bg-cyan-700 flex justify-center items-center">Slide 1</div>
                    <div className="embla__slide bg-cyan-800 flex justify-center items-center">Slide 2</div>
                    <div className="embla__slide bg-cyan-900 flex justify-center items-center">Slide 3</div>
                </div>
            </div>
            {/* --------------------------------- */}
            
        </div>
    )
}
 
export default Carousel;


Setup 2: Separated Slide Component

To make the code cleaner and more maintainable, we move each slide into its own reusable <Slide /> component. We also prepare slide data as an array of objects (slideInfo) so it's easier to map over and extend. This is a common pattern in real-world carousels that display content like cities, products, or blog posts.


app/components/CarouselB.tsx
'use client'
import useEmblaCarousel from "embla-carousel-react";
import Slide from "./Slide";
 
const slideInfo = [
    {
        city: "tokyo",
        image: "/images/tokyo.jpg",
        photographer: "Willian Justen de Vasconcellos"
    },
    {
        city: "barcelona",
        image: "/images/barcelona.jpg",
        photographer: "Alina Shazka",
    },
    {
        city: "cairo",
        image: "/images/cairo.jpg",
        photographer: "Alex Azabache",
    }
]
 
const CarouselB = () => {
 
    const [emblaRef] = useEmblaCarousel({ loop: true });
 
    return (
        <div>
            <h2 className="mb-4">Separated Slide Component Setup</h2>
 
            {/* Embla Scope */}
            <div ref={emblaRef} className="embla my-0">
                <div className="embla__container  h-[450px]">
                    {slideInfo.map((item) => (
                        <div key={item.city} className="embla__slide">
                            <Slide city={item.city} image={item.image} photographer={item.photographer} />
                        </div>
                    ))}
                </div>
            </div>
            {/* --------------------------------- */}
 
        </div>
    )
}
 
export default CarouselB;
app/components/Slide.tsx
import Image from "next/image";
 
interface SlideProps {
    city: string;
    image: string;
    photographer: string;
}
 
const Slide = ({ city, image, photographer }: SlideProps ) => {
    return (
        <div className="flex w-full h-full bg-slate-900">
            <div className="flex flex-col gap-2 w-[50%] justify-center items-center">
                <h2 className="font-bold text-4xl uppercase text-white">{city}</h2>
                <span>By {photographer}</span>
            </div>
            <div className="flex w-[50%] relative">
                <Image
                    src={image}
                    alt={city}
                    fill
                    priority
                    className="object-cover overflow-hidden"
                />
            </div>
        </div>
    )
}
 
export default Slide;


Setup 3: Adding Arrow Navigation

In this step, we access the emblaApi returned by useEmblaCarousel() to enable previous/next navigation. Two buttons (< and >) call scrollPrev() and scrollNext() to move between slides programmatically. This gives users more control and improves UX on non-touch devices. Note: We can use the same Slide component made in Setup 2


app/components/CarouselC.tsx
'use client'
import useEmblaCarousel from "embla-carousel-react";
import Slide from "./Slide";
 
const slideInfo = [
    {
        city: "tokyo",
        image: "/images/tokyo.jpg",
        photographer: "Willian Justen de Vasconcellos"
    },
    {
        city: "barcelona",
        image: "/images/barcelona.jpg",
        photographer: "Alina Shazka",
    },
    {
        city: "cairo",
        image: "/images/cairo.jpg",
        photographer: "Alex Azabache",
    }
]
 
const CarouselC = () => {
 
    const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
 
    const scrollPrev = () => emblaApi?.scrollPrev();
    const scrollNext = () => emblaApi?.scrollNext();
 
    return (
        <div>
            <div className="flex w-full justify-between items-center">
                <h2 className="mb-4">Adding Arrow Navigation</h2>
                <div className="flex items-center gap-4 text-xl">
                    <button onClick={scrollPrev}>&lt;</button>
                    <button onClick={scrollNext}>&gt;</button>
                </div>
            </div>
 
            {/* Embla Scope */}
            <div ref={emblaRef} className="embla my-0">
                <div className="embla__container  h-[450px]">
                    {slideInfo.map((item) => (
                        <div key={item.city} className="embla__slide">
                            <Slide city={item.city} image={item.image} photographer={item.photographer} />
                        </div>
                    ))}
                </div>
            </div>
            {/* --------------------------------- */}
 
        </div>
    )
}
 
export default CarouselC;


Adding Dot Navigation

Here we add dot-based pagination. Using emblaApi.selectedScrollSnap(), we track the currently visible slide and update selectedIndex in React state. Each dot is a button that calls emblaApi.scrollTo(index) to jump directly to a slide. This setup gives your users both swipe and click-based navigation — ideal for carousels with visual indicators. Note: We are using the same Slide component created during CarouselB Setup


app/components/CarouselD.tsx
'use client'
import useEmblaCarousel from "embla-carousel-react";
import Slide from "./Slide";
import { useEffect, useState } from "react";
 
const slideInfo = [
    {
        city: "tokyo",
        image: "/images/tokyo.jpg",
        photographer: "Willian Justen de Vasconcellos"
    },
    {
        city: "barcelona",
        image: "/images/barcelona.jpg",
        photographer: "Alina Shazka",
    },
    {
        city: "cairo",
        image: "/images/cairo.jpg",
        photographer: "Alex Azabache",
    }
]
 
const CarouselD = () => {
 
    const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true });
 
    const scrollPrev = () => emblaApi?.scrollPrev();
    const scrollNext = () => emblaApi?.scrollNext();
 
    const scrollTo = (index: number) => emblaApi?.scrollTo(index);
    const [selectedIndex, setSelectedIndex] = useState(0);
 
    useEffect(() => {
        if (!emblaApi) return;
 
        const onSelect = () => {
            setSelectedIndex(emblaApi.selectedScrollSnap());
        };
 
        emblaApi.on('select', onSelect); // listen to slide changes
        onSelect(); // run it once on mount
    }, [emblaApi])
 
    return (
        <div>
            <div className="flex w-full justify-between items-center">
                <h2 className="mb-4">Adding Dot Navigation</h2>
                <div className="flex items-center gap-4 text-xl">
                    <button onClick={scrollPrev}>&lt;</button>
                    {slideInfo.map((_, i) => (
                        <button
                            key={i}
                            onClick={() => scrollTo(i)}
                            className={`w-3 h-3 ${
                                i === selectedIndex ? 'bg-teal-300' : 'bg-gray-700'
                            }`}
                        />
                    ))}
                    <button onClick={scrollNext}>&gt;</button>
                </div>
            </div>
 
            {/* Embla Scope */}
            <div ref={emblaRef} className="embla my-0">
                <div className="embla__container  h-[450px]">
                    {slideInfo.map((item) => (
                        <div key={item.city} className="embla__slide">
                            <Slide city={item.city} image={item.image} photographer={item.photographer} />
                        </div>
                    ))}
                </div>
            </div>
            {/* --------------------------------- */}
 
        </div>
    )
}
 
export default CarouselD;


Preview

To preview all carousel setups side by side, we include each variation inside the home page. This makes it easier to compare the design and behavior of each version as you scroll.

app/page.tsx
import Carousel from "./components/Carousel";
import CarouselB from "./components/CarouselB";
import CarouselC from "./components/CarouselC";
import CarouselD from "./components/CarouselD";
 
export default function Home() {
	return (
		<div className="flex flex-col gap-[40px] w-full max-w-[1280px] mx-auto py-[40px]">
			<Carousel />
			<CarouselB />
			<CarouselC />
			<CarouselD />
		</div>
	);
}

References:

JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved