Content Carousel
A horizontal card browser: previous and next arrows around one, two or three content slots per view.
Installation
bash
npx @smicolon/fasla-ui@latest add content-carouselRun init once per project before adding components. See Installation.
Preview
Each card is a slot that takes any child. It rides the same scroll-snap engine as the Carousel, so the cards also move by swipe and trackpad.
Variants
Figma's Type axis is itemsPerView: the cards split the row evenly, each ceding its share of the 16px gaps, and the arrows always advance one card.
itemsPerView={1}itemsPerView={2}itemsPerView={3}States
The arrows disable at the ends, dimmed to half opacity: the backward arrow on the first card, the forward arrow on the last page. When everything is already in view, both are off.
First card
Last page
Everything in view
Accessibility
- The root is a
regionwitharia-roledescriptionset tocarousel; always pass anaria-labelto name it. - The arrows are buttons named by
previousLabelandnextLabel, so the keyboard reaches them and a screen reader hears your locale. At the ends they disable. - The arrow keys move one card from anywhere inside — arrows, cards or track — following the text direction. Home jumps to the first card, End to the last.
- The arrows are Buttons, so keyboard focus draws the Button's 2px ring, 2px outside the arrow. The track draws the same ring when it has focus.
- Each card is a
groupnamed with digits, “2 / 4”; passaria-labelon a card to name it by its content instead. - Smooth scrolling comes from CSS alone, and
prefers-reduced-motionmakes every move instant without any JavaScript. - Direction is inherited from
dir, never a prop. The arrows swap sides with the row and their icons mirror themselves.
Props
ContentCarousel
| Prop | Type | Default | Description |
|---|---|---|---|
itemsPerView | 1 | 2 | 3 | 1 | How many cards share the view: Figma's Type axis. |
activeIndex | number | — | First visible card, when controlled. |
defaultActiveIndex | number | 0 | Card shown first at the start. |
onActiveIndexChange | (index) => void | — | Called when the first visible card changes. |
previousLabel | string | "Previous" | Accessible name for the backward arrow. |
nextLabel | string | "Next" | Accessible name for the forward arrow. |
aria-label | string | — | Names the carousel for assistive technology. Always pass it, or aria-labelledby. |
children | ReactNode | — | The cards, one ContentCarouselItem each. |
className | string | — | Extra classes for the part. |
ContentCarouselItem
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label | string | — | Names the card by its content. The default is its position, “2 / 4”. |
children | ReactNode | — | The card's content: text with padding of its own, or media edge to edge. |
className | string | — | Extra classes for the part. |
Usage
Cards with arrows
tsx
import {
ContentCarousel,
ContentCarouselItem,
} from "@/components/ui/content-carousel"
export function CustomerStories() {
return (
<ContentCarousel
aria-label="Customer stories"
previousLabel="Previous"
nextLabel="Next"
>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The components dropped into our codebase without a fight, and the defaults already looked designed.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The empty and error states were already thought through, which is usually where libraries leave you alone.</blockquote>
</ContentCarouselItem>
</ContentCarousel>
)
}Three cards per view
tsx
import {
ContentCarousel,
ContentCarouselItem,
} from "@/components/ui/content-carousel"
export function CustomerStories() {
return (
<ContentCarousel
aria-label="Customer stories"
previousLabel="Previous"
nextLabel="Next"
itemsPerView={3}
>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The components dropped into our codebase without a fight, and the defaults already looked designed.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The empty and error states were already thought through, which is usually where libraries leave you alone.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">Storybook, docs and tokens all agree with each other. That is rarer than it should be.</blockquote>
</ContentCarouselItem>
</ContentCarousel>
)
}Edge-to-edge media: the slot adds no padding of its own
tsx
import {
ContentCarousel,
ContentCarouselItem,
} from "@/components/ui/content-carousel"
const PHOTOS = [
{ src: "/photos/old-town.jpg", alt: "The old town at dawn" },
{ src: "/photos/harbour.jpg", alt: "The harbour market" },
{ src: "/photos/corniche.jpg", alt: "The corniche at sunset" },
]
export function PhotoTour() {
return (
<ContentCarousel
aria-label="City guide"
previousLabel="Previous"
nextLabel="Next"
itemsPerView={2}
>
{PHOTOS.map((photo) => (
<ContentCarouselItem key={photo.src}>
<img src={photo.src} alt={photo.alt} className="h-full w-full object-cover" />
</ContentCarouselItem>
))}
</ContentCarousel>
)
}Controlled
tsx
"use client"
import * as React from "react"
import {
ContentCarousel,
ContentCarouselItem,
} from "@/components/ui/content-carousel"
export function CustomerStories() {
const [index, setIndex] = React.useState(0)
return (
<ContentCarousel
aria-label="Customer stories"
previousLabel="Previous"
nextLabel="Next"
itemsPerView={2}
activeIndex={index}
onActiveIndexChange={setIndex}
>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The components dropped into our codebase without a fight, and the defaults already looked designed.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.</blockquote>
</ContentCarouselItem>
<ContentCarouselItem>
<blockquote className="p-6 text-sm">The empty and error states were already thought through, which is usually where libraries leave you alone.</blockquote>
</ContentCarouselItem>
</ContentCarousel>
)
}