Skip to content
Drivn
4 min read

Next.js Carousel — Embla Slider for App Router

Add a Next.js carousel with the Drivn Embla wrapper. A single-file client island with arrows, dots, keyboard nav, and vertical mode for App Router.

A carousel is one of the few primitives where the hard part is not the markup but the scroll mechanics — snap points, drag inertia, edge detection, and keyboard control. Building that by hand in a Next.js App Router project means a tangle of useRef, useEffect, and event listeners that is easy to get subtly wrong. The pragmatic answer is to lean on a proven scroll engine and keep your own surface thin.

Drivn's Carousel is a small wrapper over Embla, the dependency-light scroll library. After install it lives in src/components/ui/carousel.tsx, marks itself 'use client', and exposes a dot-notation API — Carousel.Content, Carousel.Item, Carousel.Previous, Carousel.Next, and Carousel.Dots — assembled with Object.assign. The root wires up useEmblaCarousel, tracks canScrollPrev, canScrollNext, the selected index, and the snap list in context, and adds role="region", aria-roledescription="carousel", and left/right arrow-key handling for free.

This guide installs Drivn in a Next.js 16 project, renders the Carousel as a client island, composes slides with Content and Item, adds arrow buttons and snap dots, and switches to a vertical axis. Every snippet is drawn from the component's real source. For the full reference see the Carousel docs; for the shadcn/ui comparison see Drivn vs shadcn/ui Carousel.

Install in a Next.js 16 project

Drivn installs through a tiny CLI that writes the component source file directly into your repository — there is no runtime npm package to version-lock. Open a terminal in the root of your Next.js 16 project and run npx drivn add carousel. The CLI prompts once for your install directory (defaulting to src/components/ui/), copies carousel.tsx, and adds its dependencies — embla-carousel-react for the scroll engine and lucide-react for the chevron icons on the arrow buttons — to your package.json if they are missing. The Carousel also composes the Drivn Button for its Previous and Next controls, so the CLI installs that file alongside it. The CLI reference documents every flag, including how to target a custom path. After install you own the file; future releases will not overwrite it.

1# from the root of your Next.js 16 project
2npx drivn add carousel
3
4# the CLI also pulls in the Button it composes for arrows

Render as a client island

An App Router page is a server component by default, so it cannot run the hooks Embla relies on. Drivn's Carousel is marked 'use client' at the top of its source because the root calls useEmblaCarousel and holds scroll state with useState. The clean pattern is to keep the carousel in a small client component and render that island inside an otherwise server-rendered page. Import from @/components/ui/carousel, then compose the slides — no dynamic() import and no SSR-disable flag are needed, because Next.js inserts the client boundary automatically at the 'use client' directive. The installation guide covers project bootstrapping; the Carousel docs list the root props.

1'use client'
2import { Carousel } from '@/components/ui/carousel'
3
4export function Gallery() {
5 return (
6 <Carousel className="max-w-md">
7 <Carousel.Content>
8 <Carousel.Item>Slide one</Carousel.Item>
9 <Carousel.Item>Slide two</Carousel.Item>
10 <Carousel.Item>Slide three</Carousel.Item>
11 </Carousel.Content>
12 </Carousel>
13 )
14}

Compose slides with Content and Item

The structure is two nested pieces. Carousel.Content renders the Embla viewport — it attaches emblaRef to the scrolling div and wraps your slides in a flex container. Carousel.Item is each slide: its base classes are min-w-0 shrink-0 grow-0 basis-full, so every item fills the viewport width by default and the carousel shows one slide at a time. To show multiple slides per view, override basis on the item with a Tailwind fraction — basis-1/2 for two across, basis-1/3 for three. Each Carousel.Item also carries role="group" and aria-roledescription="slide" so assistive technology announces position correctly. Map your data straight into Carousel.Item children; there is no fixed slide count baked in.

1<Carousel.Content className="-ml-4">
2 {products.map((product) => (
3 <Carousel.Item key={product.id} className="pl-4 basis-1/3">
4 <ProductCard product={product} />
5 </Carousel.Item>
6 ))}
7</Carousel.Content>

Add arrows and snap dots

Navigation comes as three drop-in sub-components. Carousel.Previous and Carousel.Next render Drivn Buttons (default variant="secondary", size="sm") absolutely positioned to the left and right of the track; each reads canScrollPrev / canScrollNext from context and disables itself automatically at the edges, with a built-in aria-label. Carousel.Dots maps over Embla's snap list, rendering one button per slide and highlighting the active index with bg-foreground. Because all of these consume the shared context, they stay in sync with drag, keyboard, and programmatic scrolling without any wiring on your part. Drop them as siblings of Carousel.Content inside the root.

1<Carousel>
2 <Carousel.Content>
3 <Carousel.Item>Slide one</Carousel.Item>
4 <Carousel.Item>Slide two</Carousel.Item>
5 </Carousel.Content>
6 <Carousel.Previous />
7 <Carousel.Next />
8 <Carousel.Dots />
9</Carousel>

Vertical axis and Embla options

The root accepts an orientation prop — pass orientation="vertical" and the component sets Embla's axis: 'y', flips the container to flex-col h-full, and switches the item spacing from horizontal to vertical padding. The same arrow and dot controls keep working on the vertical axis. To configure Embla itself — loop mode, drag-free scrolling, alignment, or auto-play plugins — pass the opts and plugins props straight through; they forward to useEmblaCarousel untouched. You can also grab the underlying API with the setApi callback for fully programmatic control. The Carousel examples page shows loop and multi-slide layouts in full.

1<Carousel
2 orientation="vertical"
3 opts={{ loop: true, align: 'start' }}
4 className="h-64"
5>
6 <Carousel.Content>
7 <Carousel.Item>Row one</Carousel.Item>
8 <Carousel.Item>Row two</Carousel.Item>
9 </Carousel.Content>
10</Carousel>
Get started

Install Drivn in one command

Copy the source into your project and own every line. Zero runtime dependencies, pure React + Tailwind.

npx drivn@latest create

Requires Node 18+. Works with npm, pnpm, and yarn.

Enjoying Drivn?
Star the repo on GitHub to follow new component releases.
Star →

Frequently asked questions

The Carousel is a client component — its source is marked 'use client' because the root calls useEmblaCarousel and tracks scroll state with hooks. Your surrounding page and layout still render on the server. The usual pattern is a small client component holding the carousel, rendered as an island inside an otherwise server-rendered route. Next.js inserts the client boundary at the import automatically.

Override the basis class on each Carousel.Item. The base is basis-full, which fills the viewport for a one-slide view. Pass className="basis-1/2" for two slides across or basis-1/3 for three, and pair it with horizontal padding for the gap. Embla recalculates its snap points from the item widths, so the arrows and dots stay accurate at any count.

Yes. The root forwards an opts prop straight to useEmblaCarousel, so opts={{ loop: true }} or opts={{ dragFree: true }} works without touching the source. Autoplay and other behaviors come from Embla plugins passed via the plugins prop. Both props are typed against Embla's own parameter types, so your editor autocompletes the available options.

Yes. The Carousel root has tabIndex={0} and an onKeyDown handler that scrolls to the previous slide on ArrowLeft and the next slide on ArrowRight, calling preventDefault so the page does not also scroll. The root carries role="region" and aria-roledescription="carousel", and each slide is a role="group" with aria-roledescription="slide", so the component is operable and announced without extra wiring.