Skip to content
    Style: Wireframe

    Components · Display

    Carousel

    A swipeable row of cards with Previous and Next that turn off at the ends. No autoplay.

    22% of the 2,073 sites in The UI Index have a carousel (463 sites).

    Both previews render the same markup; the HTML tab below is that markup without a framework.

    Install

    React
    npx shadcn@latest add https://www.themes.dev/ui/r/carousel.json
    Astro
    npx shadcn@latest add https://www.themes.dev/ui/r/astro/carousel.json

    Or copy the files below into your project. They need clsx, tailwind-merge, and the themes.dev UI theme. The HTML needs only the compiled CSS and the script.

    carousel.client.ts
    // Carousel behaviour, shared by the Astro, React and HTML versions: Previous and Next scroll the track by one slide
    // (smooth, or at once under reduced motion via CSS), and turn off at the ends; the state follows any scrolling
    // (swipe, wheel, Tab).
    declare global {
      interface Window {
        __caseroomCarousel?: boolean;
      }
    }
    
    const sync = (root: Element) => {
      const track = root.querySelector<HTMLElement>('[data-carousel-track]');
      if (!track) return;
      const prev = root.querySelector<HTMLButtonElement>('[data-carousel-prev]');
      const next = root.querySelector<HTMLButtonElement>('[data-carousel-next]');
      if (prev) prev.disabled = track.scrollLeft <= 1;
      if (next) next.disabled = track.scrollLeft + track.clientWidth >= track.scrollWidth - 1;
    };
    
    export function wireCarousel(root: Element) {
      const track = root.querySelector<HTMLElement>('[data-carousel-track]');
      if (!track || track.dataset.wired) return;
      track.dataset.wired = '';
      track.addEventListener('scroll', () => sync(root), { passive: true });
      sync(root);
    }
    
    if (typeof window !== 'undefined' && !window.__caseroomCarousel) {
      window.__caseroomCarousel = true;
      const all = () => document.querySelectorAll('[data-carousel]').forEach(wireCarousel);
      if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', all);
      else all();
      addEventListener('resize', () => document.querySelectorAll('[data-carousel]').forEach(sync));
      document.addEventListener('click', (e) => {
        const b = (e.target as Element).closest<HTMLButtonElement>('[data-carousel-prev], [data-carousel-next]');
        if (!b) return;
        const root = b.closest('[data-carousel]')!;
        wireCarousel(root);
        const track = root.querySelector<HTMLElement>('[data-carousel-track]')!;
        const slide = track.querySelector<HTMLElement>(':scope > *');
        const step = (slide?.getBoundingClientRect().width ?? track.clientWidth) + parseFloat(getComputedStyle(track).columnGap || '0');
        track.scrollBy({ left: b.matches('[data-carousel-next]') ? step : -step });
      });
    }
    carousel.tsx
    import { useEffect, useRef, type ReactNode } from 'react';
    import { cn } from '@/lib/utils';
    import { wireCarousel } from './carousel.client';
    import { carousel as c, NEXT_PATH, PREV_PATH } from './carousel.variants';
    
    export interface CarouselProps {
      /** The heading over the row, also the region's name. */
      title: string;
      id: string;
      /** The slides: <li className={carouselSlide}> elements. */
      children: ReactNode;
      className?: string;
    }
    
    /** A swipeable row of cards with Previous and Next; no autoplay. */
    export function Carousel({ title, id, children, className }: CarouselProps) {
      const ref = useRef<HTMLElement>(null);
      useEffect(() => {
        if (ref.current) wireCarousel(ref.current);
      }, []);
      return (
        <section ref={ref} data-carousel="" aria-labelledby={`${id}-title`} aria-roledescription="carousel" className={cn(c.root, className)}>
          <div className={c.head}>
            <h2 id={`${id}-title`} className={c.title}>{title}</h2>
            <div className={c.controls}>
              <button type="button" data-carousel-prev="" aria-controls={`${id}-track`} aria-label="Previous" disabled className={c.button}>
                <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
              </button>
              <button type="button" data-carousel-next="" aria-controls={`${id}-track`} aria-label="Next" className={c.button}>
                <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
              </button>
            </div>
          </div>
          <ul id={`${id}-track`} data-carousel-track="" className={c.track}>{children}</ul>
        </section>
      );
    }
    export const carouselSlide = c.slide;
    carousel.variants.ts
    /** A carousel of cards (ported in spirit from the themes' galleries): a scroll-snap row you can swipe, scroll or Tab
     *  through, with Previous and Next buttons that move by one card and turn off at the ends. No autoplay. The region
     *  is named; the slides are a list, so their count is announced. Edges shade where more cards wait. */
    export const carousel = {
      root: 'relative w-full',
      head: 'mb-4 flex items-end justify-between gap-4',
      title: 'text-lg font-semibold tracking-[-0.02em]',
      controls: 'flex gap-2',
      button:
        'inline-flex size-9 cursor-pointer items-center justify-center rounded-full border border-input bg-background transition-[background-color,transform] hover:bg-accent active:scale-95 active:bg-pressed disabled:pointer-events-none disabled:opacity-40 [&_svg]:size-4',
      track:
        // 8px of room inside the scroller (and a matching scroll padding), so focus rings are not cut off.
        '-mx-2 flex list-none snap-x snap-mandatory scroll-px-2 gap-4 overflow-x-auto scroll-smooth px-2 pt-2 pb-2 [scrollbar-width:none] motion-reduce:scroll-auto [&::-webkit-scrollbar]:hidden',
      slide: 'w-[min(18rem,80%)] shrink-0 snap-start',
    };
    export const PREV_PATH = 'M10 3.5L5.5 8l4.5 4.5';
    export const NEXT_PATH = 'M6 3.5l4.5 4.5L6 12.5';