Carousel
Horizontal carousel of slides — snap (single), multi (multiple visible), or marquee (auto-scroll).
Featured work
A few projects we shipped this year.
import { Carousel } from '@/foundations/blocks/carousel';
const slides = [
{ tone: 'bg-info/15 text-info', label: 'Discover' },
{ tone: 'bg-success/15 text-success', label: 'Design' },
{ tone: 'bg-warning/15 text-warning', label: 'Develop' },
{ tone: 'bg-error/15 text-error', label: 'Deliver' },
];
const Slide = ({ tone, label }: { tone: string; label: string }) => (
<div
className={`grid aspect-[16/9] w-full place-items-center font-semibold text-2xl ${tone}`}
>
{label}
</div>
);
export default function CarouselPreview() {
return (
<Carousel
title="Featured work"
description="A few projects we shipped this year."
slides={slides.map((s) => (
<Slide key={s.label} tone={s.tone} label={s.label} />
))}
/>
);
}
export const meta = {
layout: 'fullscreen',
}; Dependencies
Source Code
'use client';
import { ChevronLeft, ChevronRight } from '@untitledui-pro/icons/solid';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Container } from '@/components/container';
import { cn } from '@/lib/utils/classnames';
interface CarouselProps
extends Omit<React.ComponentPropsWithRef<'section'>, 'title'> {
title?: React.ReactNode;
description?: React.ReactNode;
/** Slides — image elements, video elements, or any node. */
slides: React.ReactNode[];
/**
* Layout mode:
* - `snap` (default) — one slide at a time, native scroll-snap, arrows + dots
* - `multi` — multiple slides visible at once, arrows
* - `marquee` — auto-scrolling seamless strip, pauses on hover
*/
variant?: 'snap' | 'multi' | 'marquee';
/** For `multi`: number of slides visible at desktop. Default 3. */
visible?: number;
/** For `marquee`: pixels per second. Default 40. */
speed?: number;
/** Required for screen readers. */
label?: string;
}
const Carousel = ({
ref,
className,
title,
description,
slides,
variant = 'snap',
visible = 3,
speed = 40,
label = 'Carousel',
...rest
}: CarouselProps) => {
const scrollerRef = useRef<HTMLDivElement | null>(null);
const [activeIndex, setActiveIndex] = useState(0);
// Track which slide is most visible (snap + multi only).
useEffect(() => {
if (variant === 'marquee') return;
const el = scrollerRef.current;
if (!el) return;
const items = Array.from(
el.querySelectorAll<HTMLElement>('[data-carousel-item]')
);
if (items.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const best = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)[0];
if (!best) return;
const idx = items.indexOf(best.target as HTMLElement);
if (idx >= 0) setActiveIndex(idx);
},
{ root: el, threshold: [0.5, 0.75, 1] }
);
for (const item of items) observer.observe(item);
return () => observer.disconnect();
}, [variant]);
const scrollToIndex = useCallback((idx: number) => {
const el = scrollerRef.current;
if (!el) return;
const items = el.querySelectorAll<HTMLElement>('[data-carousel-item]');
const target = items[idx];
if (!target) return;
el.scrollTo({
left: target.offsetLeft - el.offsetLeft,
behavior: 'smooth',
});
}, []);
const scrollBy = useCallback((dir: 1 | -1) => {
const el = scrollerRef.current;
if (!el) return;
const items = el.querySelectorAll<HTMLElement>('[data-carousel-item]');
if (items.length === 0) return;
const step = (items[0] as HTMLElement).offsetWidth + 16; // gap-4
el.scrollBy({ left: step * dir, behavior: 'smooth' });
}, []);
const showArrows = variant !== 'marquee';
const showDots = variant === 'snap';
return (
<section
ref={ref}
className={cn('w-full py-16 md:py-24', className)}
aria-roledescription="carousel"
aria-label={label}
{...rest}
>
<Container>
{(title || description || showArrows) && (
<div className="mb-8 flex items-end justify-between gap-4 md:mb-12">
<div className="flex flex-col gap-2">
{title && (
<h2 className="text-balance text-3xl md:text-4xl lg:text-5xl">
{title}
</h2>
)}
{description && (
<p className="max-w-xl text-pretty text-foreground-secondary text-lg md:text-xl">
{description}
</p>
)}
</div>
{showArrows && (
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => scrollBy(-1)}
aria-label="Previous slide"
className={cn(
'grid size-10 place-items-center rounded-full border border-border bg-background text-foreground',
'transition-colors duration-(--duration-hover) ease-out',
'hover:bg-foreground/5',
'disabled:opacity-40'
)}
disabled={activeIndex === 0}
>
<ChevronLeft className="size-4" />
</button>
<button
type="button"
onClick={() => scrollBy(1)}
aria-label="Next slide"
className={cn(
'grid size-10 place-items-center rounded-full border border-border bg-background text-foreground',
'transition-colors duration-(--duration-hover) ease-out',
'hover:bg-foreground/5',
'disabled:opacity-40'
)}
disabled={activeIndex >= slides.length - 1}
>
<ChevronRight className="size-4" />
</button>
</div>
)}
</div>
)}
</Container>
{variant === 'marquee' ? (
<div
className="group relative overflow-hidden"
// Pause on hover via CSS variable swap.
style={
{
'--marquee-duration': `${(slides.length * 320) / speed}s`,
} as React.CSSProperties
}
>
<div
className={cn(
'flex w-max gap-6 motion-reduce:animate-none',
'animate-[carousel-marquee_var(--marquee-duration)_linear_infinite]',
'group-hover:[animation-play-state:paused]'
)}
>
{[...slides, ...slides].map((slide, i) => (
<div
key={i}
className="w-72 shrink-0 overflow-hidden rounded-xl bg-surface-sunken md:w-96"
>
{slide}
</div>
))}
</div>
<style>
{`@keyframes carousel-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}`}
</style>
</div>
) : (
<Container>
<div
ref={scrollerRef}
className={cn(
'flex snap-x snap-mandatory gap-4 overflow-x-auto pb-4',
// Hide scrollbar — leave room for the dots / arrows.
'[scrollbar-width:none] [&::-webkit-scrollbar]:hidden'
)}
style={
{
'--carousel-visible': variant === 'multi' ? visible : 1,
} as React.CSSProperties
}
>
{slides.map((slide, i) => (
<div
key={i}
data-carousel-item
className={cn(
'shrink-0 snap-start overflow-hidden rounded-xl bg-surface-sunken',
variant === 'snap' && 'w-full',
variant === 'multi' &&
'w-full md:w-[calc((100%-(var(--carousel-visible)-1)*1rem)/var(--carousel-visible))]'
)}
>
{slide}
</div>
))}
</div>
</Container>
)}
{showDots && (
<Container>
<div className="mt-6 flex justify-center gap-2">
{slides.map((_, i) => (
<button
key={i}
type="button"
onClick={() => scrollToIndex(i)}
aria-label={`Go to slide ${i + 1}`}
aria-current={i === activeIndex || undefined}
className={cn(
'h-1.5 rounded-full transition-all duration-(--duration-hover) ease-out',
i === activeIndex
? 'w-6 bg-foreground'
: 'w-1.5 bg-foreground/20 hover:bg-foreground/40'
)}
/>
))}
</div>
</Container>
)}
</section>
);
};
export type { CarouselProps };
export { Carousel }; Carousel is a horizontal strip of slides driven by native scroll-snap. Three
variants cover the common marketing use cases: a one-up snap carousel with
prev/next + dot indicators, a multi-up grid carousel, and an auto-scrolling
marquee that pauses on hover.
Anatomy
<Carousel
title="Featured work"
description="A few projects we shipped this year."
slides={[<img src="..." />, <img src="..." />, <img src="..." />]}
/>
Variants
snap(default) — one slide at a time with prev/next arrows and dot indicators.multi— multiple slides visible at once (setvisibleto control). Arrows only.marquee— auto-scrolling seamless strip. Pauses on hover. No arrows or dots.
The marquee variant respects prefers-reduced-motion.
API Reference
Extends the section element.
| Prop | Default | Type |
|---|---|---|
title | - | ReactNode |
description | - | ReactNode |
slides * | - | ReactNode[] |
variant | 'snap' | 'snap''multi''marquee' |
visible | 3 | number |
speed | 40 | number |
label | 'Carousel' | string |
Examples
Default — snap
Featured work
A few projects we shipped this year.
import { Carousel } from '@/foundations/blocks/carousel';
const slides = [
{ tone: 'bg-info/15 text-info', label: 'Discover' },
{ tone: 'bg-success/15 text-success', label: 'Design' },
{ tone: 'bg-warning/15 text-warning', label: 'Develop' },
{ tone: 'bg-error/15 text-error', label: 'Deliver' },
];
const Slide = ({ tone, label }: { tone: string; label: string }) => (
<div
className={`grid aspect-[16/9] w-full place-items-center font-semibold text-2xl ${tone}`}
>
{label}
</div>
);
export default function CarouselPreview() {
return (
<Carousel
title="Featured work"
description="A few projects we shipped this year."
slides={slides.map((s) => (
<Slide key={s.label} tone={s.tone} label={s.label} />
))}
/>
);
}
export const meta = {
layout: 'fullscreen',
}; Multi — three visible at once
Recent case studies
Three at a time on desktop, scroll the rest.
import { Carousel } from '@/foundations/blocks/carousel';
const items = [
{ tone: 'bg-info/15 text-info', name: 'Acme' },
{ tone: 'bg-success/15 text-success', name: 'Initech' },
{ tone: 'bg-warning/15 text-warning', name: 'Globex' },
{ tone: 'bg-error/15 text-error', name: 'Soylent' },
{ tone: 'bg-accent/15 text-accent', name: 'Umbrella' },
{ tone: 'bg-foreground/10 text-foreground', name: 'Hooli' },
];
const Card = ({ tone, name }: { tone: string; name: string }) => (
<div className={`flex aspect-[4/5] w-full flex-col justify-end p-6 ${tone}`}>
<p className="font-semibold text-xl">{name}</p>
<p className="text-current/70 text-sm">Case study</p>
</div>
);
export default function CarouselMultiPreview() {
return (
<Carousel
variant="multi"
visible={3}
title="Recent case studies"
description="Three at a time on desktop, scroll the rest."
slides={items.map((s) => (
<Card key={s.name} tone={s.tone} name={s.name} />
))}
/>
);
}
export const meta = {
layout: 'fullscreen',
}; Marquee — auto-scrolling
Principles
Auto-scrolling strip. Hover to pause.
Pragmatic
Honest
Quiet
Composable
Owned
Documented
Tested
Shipped
Pragmatic
Honest
Quiet
Composable
Owned
Documented
Tested
Shipped
import { Carousel } from '@/foundations/blocks/carousel';
const items = [
'Pragmatic',
'Honest',
'Quiet',
'Composable',
'Owned',
'Documented',
'Tested',
'Shipped',
];
const Tile = ({ word }: { word: string }) => (
<div className="flex aspect-16/10 items-center justify-center bg-surface-default p-6">
<p className="font-semibold text-3xl">{word}</p>
</div>
);
export default function CarouselMarqueePreview() {
return (
<Carousel
variant="marquee"
title="Principles"
description="Auto-scrolling strip. Hover to pause."
slides={items.map((w) => <Tile key={w} word={w} />)}
/>
);
}
export const meta = {
layout: 'fullscreen',
}; Previous
About
Next
Core Values