Skip to content
Caseroom

Components · Display

Carousel

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

22% of the 2,062 sites in The UI Index have a carousel (459 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/caseroom/r/carousel.json
Astro
npx shadcn@latest add https://www.themes.dev/caseroom/r/astro/carousel.json

Or copy the files below into your project. They need clsx, tailwind-merge, and the Caseroom 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';