# Carousel 01

> Auto-scroll carousel that pauses on item hover and reveals title.

A shadcn/ui block from the [@flx](https://ui.flexnative.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/flx/carousel-01).

- Page, with a live preview: https://shadcn.exchange/r/flx/carousel-01
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/flx/carousel-01-light-7ce216161b295ec6.png
- Registry item: https://ui.flexnative.com/r/carousel-01.json
- Listed: 2026-10-11, source last changed 2026-10-11

## Install

```bash
npx shadcn@latest add https://ui.flexnative.com/r/carousel-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `class-variance-authority`, `motion`
- Registry items: `carousel`

## Files

### `registry/blocks/carousel/carousel-01/carousel-01.tsx`

```tsx
'use client'

import { cva } from 'class-variance-authority'
import AutoScroll from 'embla-carousel-auto-scroll'
import { motion } from 'motion/react'
import { useEffect, useRef, useState } from 'react'

import {
  Carousel,
  type CarouselApi,
  CarouselContent,
  CarouselItem,
} from '@/components/ui/carousel'
import { cn } from '@/lib/utils'

export type Carousel01Aspect = 'landscape' | 'portrait' | 'wide'

export interface Carousel01Media {
  src: string
  aspect: Carousel01Aspect
}

export interface Carousel01Item {
  title: string
  media: Carousel01Media
}

export type Carousel01TitlePlacement = 'inside' | 'outside'

export interface Carousel01Props {
  titlePlacement: Carousel01TitlePlacement
  items: Carousel01Item[]
}

const aspectVariants = cva('relative w-full overflow-hidden rounded-lg', {
  variants: {
    aspect: {
      landscape: 'aspect-[17/9]',
      portrait: 'aspect-[3/2]',
      wide: 'aspect-[21/8]',
    },
  },
  defaultVariants: {
    aspect: 'landscape',
  },
})

export function Carousel01({
  titlePlacement,
  items,
}: Readonly<Carousel01Props>) {
  const [api, setApi] = useState<CarouselApi>()
  const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
  const hoverCountRef = useRef(0)
  const resumeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)

  useEffect(() => {
    if (!api) return
    const autoScroll = api.plugins()?.autoScroll
    if (autoScroll) {
      autoScroll.play()
    }
  }, [api])

  const handleItemMouseEnter = (index: number) => {
    setHoveredIndex(index)
    hoverCountRef.current += 1
    if (resumeTimeoutRef.current) {
      clearTimeout(resumeTimeoutRef.current)
      resumeTimeoutRef.current = null
    }
    api?.plugins()?.autoScroll?.stop()
  }

  const handleItemMouseLeave = () => {
    setHoveredIndex(null)
    hoverCountRef.current -= 1
    if (hoverCountRef.current <= 0) {
      hoverCountRef.current = 0
      resumeTimeoutRef.current = setTimeout(() => {
        api?.plugins()?.autoScroll?.play()
        resumeTimeoutRef.current = null
      }, 50)
    }
  }

  const titleMotionProps = {
    initial: { y: 6, opacity: 0 },
    transition: { duration: 0.2, ease: 'easeOut' as const },
  }

  if (!items?.length) return null

  return (
    <Carousel
      setApi={setApi}
      opts={{
        align: 'center',
        loop: true,
        watchDrag: false,
      }}
      plugins={[
        AutoScroll({
          speed: 1,
          startDelay: 0,
          stopOnMouseEnter: false,
          stopOnInteraction: false,
        }),
      ]}
      className="w-full"
      aria-label="Carousel de medians"
    >
      <CarouselContent className="-ml-4">
        {items.map((item, index) => {
          const isHovered = hoveredIndex === index
          const isDimmed = hoveredIndex !== null && hoveredIndex !== index
          if (titlePlacement === 'outside') {
            return (
              <CarouselItem
                key={`${item.title}-${index}`}
                className="basis-full pl-4 md:basis-[36%]"
              >
                <button
                  type="button"
                  aria-label={item.title}
                  className={cn(
                    'flex w-full cursor-default flex-col gap-1 border-0 bg-transparent p-0 text-left transition-opacity duration-300',
                    isDimmed && 'opacity-50',
                  )}
                  onMouseEnter={() => handleItemMouseEnter(index)}
                  onMouseLeave={handleItemMouseLeave}
                  onFocus={() => handleItemMouseEnter(index)}
                  onBlur={handleItemMouseLeave}
                >
                  <motion.span
                    className="text-foreground min-h-[20px] text-sm font-medium"
                    {...titleMotionProps}
                    animate={
                      isHovered ? { y: 0, opacity: 1 } : { y: 6, opacity: 0 }
                    }
                    transition={{ duration: 0.2, ease: 'easeInOut' }}
                  >
                    {item.title}
                  </motion.span>
                  <span
                    className={cn(
                      'relative w-full',
                      aspectVariants({ aspect: item.media.aspect }),
                    )}
                  >
                    <img
                      src={item.media.src}
                      alt=""
                      loading="lazy"
                      decoding="async"
                      className="pointer-events-none absolute inset-0 size-full object-cover"
                    />
                  </span>
                </button>
              </CarouselItem>
            )
          }

          return (
            <CarouselItem
              key={`${item.title}-${index}`}
              className="basis-full pl-4 md:basis-[36%]"
            >
              <button
                type="button"
                aria-label={item.title}
                className={cn(
                  aspectVariants({ aspect: item.media.aspect }),
                  'block w-full cursor-default border-0 bg-transparent p-0 text-left transition-opacity duration-300',
                  isDimmed && 'opacity-50',
                )}
                onMouseEnter={() => handleItemMouseEnter(index)}
                onMouseLeave={handleItemMouseLeave}
                onFocus={() => handleItemMouseEnter(index)}
                onBlur={handleItemMouseLeave}
              >
                <img
                  src={item.media.src}
                  alt=""
                  loading="lazy"
                  decoding="async"
                  className="pointer-events-none absolute inset-0 size-full object-cover"
                />
                <motion.span
                  className="absolute right-0 bottom-0 left-0 bg-gradient-to-t from-black/50 to-transparent px-3 py-2.5 text-sm font-medium text-white"
                  {...titleMotionProps}
                  transition={{ duration: 0.2, ease: 'easeInOut' }}
                  initial={{ y: 8, opacity: 0 }}
                  animate={
                    isHovered ? { y: 0, opacity: 1 } : { y: 8, opacity: 0 }
                  }
                >
                  {item.title}
                </motion.span>
              </button>
            </CarouselItem>
          )
        })}
      </CarouselContent>
    </Carousel>
  )
}
```

### `registry/blocks/carousel/carousel-01/carousel-01-example.tsx`

```tsx
import { Carousel01, type Carousel01Props } from './carousel-01'

export const values = {
  titlePlacement: 'outside',
  items: [
    {
      title: 'Ship faster',
      media: {
        src: 'https://images.unsplash.com/photo-1610611742876-97e4d834d077?q=80&w=1170&auto=format&fit=crop',
        aspect: 'landscape',
      },
    },
    {
      title: 'Performance first',
      media: {
        src: 'https://images.unsplash.com/photo-1688327009265-3e47cdab9dc4?q=80&w=1169&auto=format&fit=crop',
        aspect: 'portrait' as const,
      },
    },
    {
      title: 'Stay in control',
      media: {
        src: 'https://images.unsplash.com/photo-1610210162763-6c4d6da47c8f?q=80&w=1170&auto=format&fit=crop',
        aspect: 'landscape' as const,
      },
    },
    {
      title: 'Built together',
      media: {
        src: 'https://images.unsplash.com/photo-1672917765736-c1c397a5d37f?q=80&w=1170&auto=format&fit=crop',
        aspect: 'portrait' as const,
      },
    },
  ],
} satisfies Carousel01Props

export function Carousel01Example() {
  return (
    <Carousel01
      titlePlacement={values.titlePlacement}
      items={values.items}
    />
  )
}
```
