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).
From the blog
From the blog
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 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 });
});
}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;/** 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';---
import { cn } from '@/lib/utils';
import { carousel as c, NEXT_PATH, PREV_PATH } from './carousel.variants';
interface Props {
/** The heading over the row, also the region's name. */
title: string;
id: string;
class?: string;
}
const { title, id, class: className } = Astro.props;
---
<section data-carousel aria-labelledby={`${id}-title`} aria-roledescription="carousel" class={cn(c.root, className)}>
<div class={c.head}>
<h2 id={`${id}-title`} class={c.title}>{title}</h2>
<div class={c.controls}>
<button type="button" data-carousel-prev aria-controls={`${id}-track`} aria-label="Previous" disabled class={c.button}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={PREV_PATH} /></svg>
</button>
<button type="button" data-carousel-next aria-controls={`${id}-track`} aria-label="Next" class={c.button}>
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d={NEXT_PATH} /></svg>
</button>
</div>
</div>
<ul id={`${id}-track`} data-carousel-track class={c.track}><slot /></ul>
</section>
<script>
import './carousel.client';
</script>// 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 });
});
}/** 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';<section data-carousel aria-labelledby="ex-stories-title" aria-roledescription="carousel" class="relative w-full max-w-3xl">
<div class="mb-4 flex items-end justify-between gap-4">
<h2 id="ex-stories-title" class="text-lg font-semibold tracking-[-0.02em]">From the blog</h2>
<div class="flex gap-2">
<button type="button" data-carousel-prev aria-controls="ex-stories-track" aria-label="Previous" disabled class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 3.5L5.5 8l4.5 4.5"></path></svg>
</button>
<button type="button" data-carousel-next aria-controls="ex-stories-track" aria-label="Next" class="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">
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3.5l4.5 4.5L6 12.5"></path></svg>
</button>
</div>
</div>
<ul id="ex-stories-track" data-carousel-track class="-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">
<li class="w-[min(18rem,80%)] shrink-0 snap-start">
<a href="#" class="flex h-full min-h-44 flex-col rounded-xl border bg-card p-5 transition-colors hover:border-foreground/30 active:bg-pressed">
<span class="block text-xs font-medium text-muted-foreground">Case study</span>
<span class="mt-3 block text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">Moving 40 sites in a weekend</span>
<span class="mt-auto block pt-6 text-sm text-muted-foreground">6 min</span>
</a>
</li>
<li class="w-[min(18rem,80%)] shrink-0 snap-start">
<a href="#" class="flex h-full min-h-44 flex-col rounded-xl border bg-card p-5 transition-colors hover:border-foreground/30 active:bg-pressed">
<span class="block text-xs font-medium text-muted-foreground">Guide</span>
<span class="mt-3 block text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">Preview links for every pull request</span>
<span class="mt-auto block pt-6 text-sm text-muted-foreground">4 min</span>
</a>
</li>
<li class="w-[min(18rem,80%)] shrink-0 snap-start">
<a href="#" class="flex h-full min-h-44 flex-col rounded-xl border bg-card p-5 transition-colors hover:border-foreground/30 active:bg-pressed">
<span class="block text-xs font-medium text-muted-foreground">Essay</span>
<span class="mt-3 block text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">What we learned from 1M deploys</span>
<span class="mt-auto block pt-6 text-sm text-muted-foreground">9 min</span>
</a>
</li>
<li class="w-[min(18rem,80%)] shrink-0 snap-start">
<a href="#" class="flex h-full min-h-44 flex-col rounded-xl border bg-card p-5 transition-colors hover:border-foreground/30 active:bg-pressed">
<span class="block text-xs font-medium text-muted-foreground">Guide</span>
<span class="mt-3 block text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">Domains without the waiting</span>
<span class="mt-auto block pt-6 text-sm text-muted-foreground">3 min</span>
</a>
</li>
<li class="w-[min(18rem,80%)] shrink-0 snap-start">
<a href="#" class="flex h-full min-h-44 flex-col rounded-xl border bg-card p-5 transition-colors hover:border-foreground/30 active:bg-pressed">
<span class="block text-xs font-medium text-muted-foreground">Story</span>
<span class="mt-3 block text-lg leading-snug font-semibold tracking-[-0.02em] text-balance">A calmer on-call week</span>
<span class="mt-auto block pt-6 text-sm text-muted-foreground">5 min</span>
</a>
</li>
</ul>
</section><script type="module" src="/caseroom/r/html/carousel.js"></script>