rapidUI

Carousel

Swipeable carousel with previous/next controls, built on Embla.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/carousel.json

Installs to @/components/ui/carousel.

Manual installation

Install the dependencies:

pnpm add embla-carousel-react@^8.6.0 lucide-react@^1.48.0

Add the components it builds on: button.

Copy the source into your project:

components/ui/carousel.tsx
'use client'

import useEmblaCarousel, { type UseEmblaCarouselType } from 'embla-carousel-react'
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'
import * as React from 'react'

import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'

type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]

type CarouselProps = {
	opts?: CarouselOptions
	plugins?: CarouselPlugin
	orientation?: 'horizontal' | 'vertical'
	setApi?: (api: CarouselApi) => void
}

type CarouselContextProps = {
	carouselRef: ReturnType<typeof useEmblaCarousel>[0]
	api: ReturnType<typeof useEmblaCarousel>[1]
	scrollPrev: () => void
	scrollNext: () => void
	canScrollPrev: boolean
	canScrollNext: boolean
} & CarouselProps

const CarouselContext = React.createContext<CarouselContextProps | null>(null)

function useCarousel() {
	const context = React.useContext(CarouselContext)

	if (!context) {
		throw new Error('useCarousel must be used within a <Carousel />')
	}

	return context
}

function Carousel({
	orientation = 'horizontal',
	opts,
	setApi,
	plugins,
	className,
	children,
	...props
}: React.ComponentProps<'div'> & CarouselProps) {
	const [carouselRef, api] = useEmblaCarousel(
		{
			...opts,
			axis: orientation === 'horizontal' ? 'x' : 'y',
		},
		plugins,
	)
	const [canScrollPrev, setCanScrollPrev] = React.useState(false)
	const [canScrollNext, setCanScrollNext] = React.useState(false)

	const onSelect = React.useCallback((api: CarouselApi) => {
		if (!api) return
		setCanScrollPrev(api.canScrollPrev())
		setCanScrollNext(api.canScrollNext())
	}, [])

	const scrollPrev = React.useCallback(() => {
		api?.scrollPrev()
	}, [api])

	const scrollNext = React.useCallback(() => {
		api?.scrollNext()
	}, [api])

	const handleKeyDown = React.useCallback(
		(event: React.KeyboardEvent<HTMLDivElement>) => {
			if (event.key === 'ArrowLeft') {
				event.preventDefault()
				scrollPrev()
			} else if (event.key === 'ArrowRight') {
				event.preventDefault()
				scrollNext()
			}
		},
		[scrollPrev, scrollNext],
	)

	React.useEffect(() => {
		if (!api || !setApi) return
		setApi(api)
	}, [api, setApi])

	React.useEffect(() => {
		if (!api) return
		onSelect(api)
		api.on('reInit', onSelect)
		api.on('select', onSelect)

		return () => {
			api?.off('select', onSelect)
		}
	}, [api, onSelect])

	const contextValue = React.useMemo<CarouselContextProps>(
		() => ({
			carouselRef,
			api,
			opts,
			orientation: orientation || (opts?.axis === 'y' ? 'vertical' : 'horizontal'),
			scrollPrev,
			scrollNext,
			canScrollPrev,
			canScrollNext,
		}),
		[carouselRef, api, opts, orientation, scrollPrev, scrollNext, canScrollPrev, canScrollNext],
	)

	return (
		<CarouselContext.Provider value={contextValue}>
			<div
				onKeyDownCapture={handleKeyDown}
				className={cn('relative', className)}
				role="region"
				aria-roledescription="carousel"
				data-slot="carousel"
				{...props}
			>
				{children}
			</div>
		</CarouselContext.Provider>
	)
}

function CarouselContent({ className, ...props }: React.ComponentProps<'div'>) {
	const { carouselRef, orientation } = useCarousel()

	return (
		<div ref={carouselRef} className="overflow-hidden" data-slot="carousel-content">
			<div className={cn('flex', orientation === 'horizontal' ? '-ml-4' : '-mt-4 flex-col', className)} {...props} />
		</div>
	)
}

function CarouselItem({ className, ...props }: React.ComponentProps<'div'>) {
	const { orientation } = useCarousel()

	return (
		<div
			role="group"
			aria-roledescription="slide"
			data-slot="carousel-item"
			className={cn('min-w-0 shrink-0 grow-0 basis-full', orientation === 'horizontal' ? 'pl-4' : 'pt-4', className)}
			{...props}
		/>
	)
}

function CarouselPrevious({
	className,
	variant = 'outline',
	size = 'icon-sm',
	...props
}: React.ComponentProps<typeof Button>) {
	const { orientation, scrollPrev, canScrollPrev } = useCarousel()

	return (
		<Button
			data-slot="carousel-previous"
			variant={variant}
			size={size}
			className={cn(
				'absolute touch-manipulation rounded-full',
				orientation === 'horizontal' ? 'inset-y-0 -left-12 my-auto' : '-top-12 left-1/2 -translate-x-1/2 rotate-90',
				className,
			)}
			disabled={!canScrollPrev}
			onClick={scrollPrev}
			{...props}
		>
			<ChevronLeftIcon />
			<span className="sr-only">Previous slide</span>
		</Button>
	)
}

function CarouselNext({
	className,
	variant = 'outline',
	size = 'icon-sm',
	...props
}: React.ComponentProps<typeof Button>) {
	const { orientation, scrollNext, canScrollNext } = useCarousel()

	return (
		<Button
			data-slot="carousel-next"
			variant={variant}
			size={size}
			className={cn(
				'absolute touch-manipulation rounded-full',
				orientation === 'horizontal' ? 'inset-y-0 -right-12 my-auto' : '-bottom-12 left-1/2 -translate-x-1/2 rotate-90',
				className,
			)}
			disabled={!canScrollNext}
			onClick={scrollNext}
			{...props}
		>
			<ChevronRightIcon />
			<span className="sr-only">Next slide</span>
		</Button>
	)
}

export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, useCarousel }

A carousel with motion and swipe built on Embla Carousel. It supports horizontal and vertical orientation, several items per view, looping and programmatic control through the Embla API, and is suited for news, highlights and image galleries.

Features

  • Horizontal and vertical orientation
  • Swipe, drag and keyboard (arrow key) navigation
  • Configurable item sizes and spacing via basis and padding classes
  • Embla options such as loop and align
  • Access to the Embla API through setApi
  • Plugin support (e.g. autoplay)

Usage

import {
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from '@/components/ui/carousel'

export function NewsCarousel() {
  return (
    <Carousel aria-label="News" className="w-full max-w-xs">
      <CarouselContent>
        <CarouselItem>New recycling station opens in Norrby</CarouselItem>
        <CarouselItem>Apply for a summer job before 15 April</CarouselItem>
        <CarouselItem>Road works on Storgatan until August</CarouselItem>
      </CarouselContent>
      <CarouselPrevious />
      <CarouselNext />
    </Carousel>
  )
}

Examples

Default

One news card per slide with previous and next buttons. The examples use token-based placeholders where your site would show photos.

Multiple Items

Several items per view using basis-* classes on CarouselItem, here upcoming events.

Spacing

Tighter spacing: a negative margin on CarouselContent (-ml-2) matched by padding on each item (pl-2).

Vertical

orientation="vertical" with two service notices per view. Give CarouselContent a fixed height.

Loop

opts={{ loop: true }} goes back to the first photo after the last.

With Api

setApi gives access to the Embla API, used here for step dots that show and change the current step of a guide.

Accessibility

  • The root has role="region" and aria-roledescription="carousel"
  • Each item has role="group" and aria-roledescription="slide"
  • Arrow keys move between slides when the carousel has focus
  • Previous and next buttons include screen reader labels and are disabled at the edges
  • Avoid autoplay, or provide a way to pause it