Carousel#

A scroll-snap viewport with an active-index model. The scrolling is the mechanism: the viewport is a real overflow container, each item a snap stop, and the model is derived from where the user actually scrolled — then driven back by scrollIntoView when the app sets it. The root is a labelled region with aria-roledescription="carousel", so label is required.

Import#

TSX
import { Carousel } from '@sigx/zero/carousel';

Carousel is a compound: Carousel.Root, Carousel.Viewport, Carousel.Item, Carousel.PrevTrigger, Carousel.NextTrigger, Carousel.IndicatorGroup, Carousel.Indicator. It is also re-exported from the @sigx/zero root, together with carouselAnatomy and useCarouselContext.

Usage#

TSX
import { component } from 'sigx';
import { Carousel } from '@sigx/zero/carousel';

const Featured = component(({ signal }) => {
    const state = signal({ slide: 0 });
    const photos = ['/a.jpg', '/b.jpg', '/c.jpg'];

    return () => (
        <Carousel.Root label="Featured photos" model={() => state.slide}>
            <Carousel.Viewport>
                {photos.map((src) => (
                    <Carousel.Item><img src={src} alt="" /></Carousel.Item>
                ))}
            </Carousel.Viewport>
            <Carousel.PrevTrigger>‹</Carousel.PrevTrigger>
            <Carousel.NextTrigger>›</Carousel.NextTrigger>
            <Carousel.IndicatorGroup>
                {photos.map((_, i) => <Carousel.Indicator index={i} />)}
            </Carousel.IndicatorGroup>
        </Carousel.Root>
    );
});

model={() => state.slide} binds the active index both ways — scrolling or pressing a control writes state.slide, and writing state.slide scrolls the slide into view. Leave the model off and pass defaultIndex to keep the state inside the component; indexChange fires either way. See Models.

Items register in DOM order, so the indicator's index is the item's position in the viewport, zero-based. The item count is reactive: "n of m" labels and the trigger bounds update as items arrive or leave.

How the model follows the scroll#

The viewport creates an IntersectionObserver in onMounted — the server renders the resting markup and never observes — thresholded so the item owning most of the viewport owns the model. A model write that came from the observer does not scroll back, so setting the model never fights the user's finger. When the initial index is not 0 the viewport jumps there instantly on mount; every later model change scrolls smoothly, unless prefers-reduced-motion: reduce asks for a jump.

Labels#

TSX
<Carousel.Item label="Sunrise over the bay">…</Carousel.Item>
<Carousel.PrevTrigger label="Earlier photo" />
<Carousel.Indicator index={0} label="Sunrise" />

Each item is a group with aria-roledescription="slide" named "n of m" by default; the triggers default to "Previous slide" / "Next slide", and each dot to "Go to slide n". Pass label on any of them to override.

Anatomy#

PartElementStatesFlagsNotes
rootdivrole="region", aria-roledescription="carousel", aria-label. Carries the variant axes.
viewportdivThe scroll container; tabIndex=0. Inside root.
itemdivactive | inactiverole="group", aria-roledescription="slide", aria-label. Inside viewport.
prev-triggerbuttondisabled, focus-visible, pressed, press-animatingDisabled on the first slide. Publishes press feedback.
next-triggerbuttondisabled, focus-visible, pressed, press-animatingDisabled on the last slide.
indicator-groupdivHolds the dots. Inside root.
indicatorbuttonactive | inactivedisabled, focus-visible, pressedaria-current="true" when active. Inside indicator-group.

Every part carries data-scope="carousel" and data-part="<part>". The viewport is a scrollable region, so it is a tab stop: focused, the platform's arrow keys scroll it — the swipe gesture's keyboard equivalent. The prev / next triggers clamp rather than wrap ("1 of 5" after "5 of 5" reads as a bug) and disable at their bounds. The dots are plain labelled buttons, not tabs — no roving tabindex, every dot its own stop. The carousel is horizontal only: a vertical scroll-snap gallery is a scrolling page, not a carousel. See The anatomy contract.

Props#

Carousel.Root#

PropTypeDefaultDescription
modelnumberTwo-way binding of the active slide index (zero-based).
defaultIndexnumber0Initial index when uncontrolled.
indexChangeevent (index: number)Fires whenever the active index changes.
labelstringrequiredAccessible name of the region.
color / size / variant / axes / modsdesign-system vocabularyThe variant axes, rendered as data-* on root.
classstringExtra classes on the root element.

Carousel.Viewport, Carousel.IndicatorGroup#

Only class.

Carousel.Item#

PropTypeDefaultDescription
labelstring"n of m"Accessible name of the slide.
classstringExtra classes.

Carousel.PrevTrigger, Carousel.NextTrigger#

PropTypeDefaultDescription
labelstring'Previous slide' / 'Next slide'Accessible name for an icon-only trigger.
classstringExtra classes.

Carousel.Indicator#

PropTypeDefaultDescription
indexnumberrequiredWhich slide this dot names and activates.
labelstring"Go to slide n"Accessible name of the dot.
classstringExtra classes.

Keyboard#

KeyAction
TabMoves through the viewport, the triggers and each dot — every control is its own stop.
ArrowLeft / ArrowRight (viewport focused)The platform scrolls the viewport; the snap lands on a slide and the model follows.
Enter / Space (trigger or dot focused)Step one slide, or jump to the dot's slide.

In the shipped design systems#

Both @sigx/zero-basic and @sigx/zero-daisyui wire color (the eight recommended roles) and size (xsxl) on the carousel root, so <Carousel.Root color="primary" size="sm"> is styled in both. Neither wires a variant or any mods on the scope; under a design system's /register import those props are therefore absent. See Typed vocabulary.

The recipe owns the snap: viewport gets overflow-x: auto, scroll-snap-type: x mandatory and a flex row, each item gets scroll-snap-align and a width, and the runtime never sets a scroll style. The indicator is a paint part — it carries no text — so a design system paints the dot from the color axis and tells active from inactive by fill; the contrast audit grades both states.

Tabs for panels that switch without scrolling, Diff for comparing two images in one frame.