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-carousel

Run 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.

The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Storybook, docs and tokens all agree with each other. That is rarer than it should be.
Karim, staff engineer

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}
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Storybook, docs and tokens all agree with each other. That is rarer than it should be.
Karim, staff engineer
itemsPerView={2}
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Storybook, docs and tokens all agree with each other. That is rarer than it should be.
Karim, staff engineer
itemsPerView={3}
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Storybook, docs and tokens all agree with each other. That is rarer than it should be.
Karim, staff engineer

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
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Last page
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer
Everything in view
The components dropped into our codebase without a fight, and the defaults already looked designed.
Salma, frontend lead
We shipped the Arabic version of our dashboard in a week. The direction handling simply worked.
Omar, product engineer
The empty and error states were already thought through, which is usually where libraries leave you alone.
Lina, design engineer

Accessibility

  • The root is a region with aria-roledescription set to carousel; always pass an aria-label to name it.
  • The arrows are buttons named by previousLabel and nextLabel, 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 group named with digits, “2 / 4”; pass aria-label on a card to name it by its content instead.
  • Smooth scrolling comes from CSS alone, and prefers-reduced-motion makes 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

PropTypeDefaultDescription
itemsPerView1 | 2 | 31How many cards share the view: Figma's Type axis.
activeIndexnumber—First visible card, when controlled.
defaultActiveIndexnumber0Card shown first at the start.
onActiveIndexChange(index) => void—Called when the first visible card changes.
previousLabelstring"Previous"Accessible name for the backward arrow.
nextLabelstring"Next"Accessible name for the forward arrow.
aria-labelstring—Names the carousel for assistive technology. Always pass it, or aria-labelledby.
childrenReactNode—The cards, one ContentCarouselItem each.
classNamestring—Extra classes for the part.

ContentCarouselItem

PropTypeDefaultDescription
aria-labelstring—Names the card by its content. The default is its position, “2 / 4”.
childrenReactNode—The card's content: text with padding of its own, or media edge to edge.
classNamestring—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>
  )
}