macbetter

Content

Carousel

Page through cards that snap into place, with the next one peeking in.

import { Artwork } from "macbetter/artwork"
import {
  Carousel,
  CarouselContent,
  CarouselIndicator,
  CarouselIndicatorGroup,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  CarouselViewport,
} from "macbetter/carousel"

const stories = [
  { id: "focus", eyebrow: "Get started", title: "Set up Focus for deep work", variant: "orbit", palette: "dusk" },
  { id: "photos", eyebrow: "Tips", title: "Find any photo by what's in it", variant: "bloom", palette: "sunset" },
  { id: "keychain", eyebrow: "Security", title: "Move to passkeys in five minutes", variant: "topo", palette: "forest" },
  { id: "journal", eyebrow: "Wellbeing", title: "Start a journal you'll keep", variant: "strata", palette: "berry" },
  { id: "shortcuts", eyebrow: "Power user", title: "Automate your morning with Shortcuts", variant: "prism", palette: "ocean" },
] as const

export default function Example() {
  return (
    <Carousel aria-label="Featured stories" style={{ width: "100%", maxWidth: 720 }}>
      <CarouselViewport>
        <CarouselContent>
          {stories.map((story) => (
            <CarouselItem key={story.id}>
              <Artwork
                seed={story.id}
                variant={story.variant}
                palette={story.palette}
                aspect={16 / 10}
                radius="var(--mb-group-radius)"
              >
                <span style={{ font: "var(--mb-text-footnote)", fontWeight: 600, textTransform: "uppercase", opacity: 0.85 }}>
                  {story.eyebrow}
                </span>
                <span style={{ font: "var(--mb-text-title-2)" }}>{story.title}</span>
              </Artwork>
            </CarouselItem>
          ))}
        </CarouselContent>
      </CarouselViewport>
      <CarouselPrevious />
      <CarouselNext />
      <CarouselIndicatorGroup aria-label="Choose a story">
        {stories.map((story) => (
          <CarouselIndicator key={story.id} aria-label={story.title} />
        ))}
      </CarouselIndicatorGroup>
    </Carousel>
  )
}

When to use it

Use a carousel for a short row of peers people browse rather than read in order: featured stories, photos, product shelves. The next card peeks in from the edge, so it's clear there's more.

  • Keep it to a handful of cards. Content people must see shouldn't be on the third page.
  • Don't rotate on a timer unless the content is decorative; if you do, keep the play toggle.
  • Use a horizontal Scroll View when items needn't snap to pages.

Usage

import {
  Carousel,
  CarouselContent,
  CarouselIndicator,
  CarouselIndicatorGroup,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
  CarouselViewport,
} from "macbetter/carousel"

Put the cards in CarouselItems inside the viewport and content. CarouselPrevious and CarouselNext draw their chevrons and are named "Previous" and "Next" unless you pass your own aria-label. Write one CarouselIndicator per card, each named after its card; dots past the page count hide themselves.

Layout

layout="peek", the default, sizes each card to most of the view so the next one peeks in, like the App Store. layout="full" shows one page at a time. To size cards yourself, set --mb-carousel-item-size.

Platform behavior

  • macOS. About two cards to a view, and glass arrow buttons that appear while the pointer is over the carousel. Trackpad swipes and the scroll wheel page it too.
  • iOS. One card to a view, inset 16 points with the next card peeking in, and no arrows: you swipe. The page dots sit under the cards.
  • iPadOS. Two cards to a view, with arrows when a pointer is connected.
  • The arrows use the regular material, so they turn opaque under Reduce Transparency. In right-to-left layouts the cards run from the right and the arrows mirror.
  • Arrows show only on devices with a fine pointer that hovers. Slides snap with the browser's scroll snapping, which follows the platform's own momentum.

Accessibility

  • The carousel follows the WAI-ARIA carousel pattern, from ui-sdk. Name it with aria-label, like "Featured stories".
  • The viewport takes focus, and Left and Right Arrow page through it. Cards out of view are inert, so Tab doesn't wander into them.
  • The page dots are tabs: one tab stop on the current page, arrows to move between them, Home and End for the ends.
  • Previous and Next stay focusable at the ends and report aria-disabled.

API

Takes every prop of ui-sdk's Carousel, including page, defaultPage, onPageChange, align, slidesPerPage, loop and controller, plus:

PropTypeDefaultDescription
layout"peek" | "full""peek"Whether the next card peeks in, or one page shows at a time.

CarouselViewport, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselIndicatorGroup and CarouselIndicator wrap the ui-sdk parts of the same names.

Data attributes and CSS variables

Attribute or variableOnMeaning
data-layout.mb-carouselpeek or full.
data-disabled.mb-carousel-buttonNo page that way.
data-selected.mb-carousel-indicatorThe current page's dot.
--mb-carousel-item-sizeplatformEach card's width.
--mb-carousel-gap, --mb-carousel-insetplatformSpace between cards, and the inset before the first.