Skip to content

Carousel

A horizontally scrolling rail — the frame around any set of cards.

import { Carousel } from "@evolve-storefront/ui/carousel";
Design notes

The scrolling itself is pure CSS: a flex row in an overflow-x-auto container with scroll-snap. Touch, trackpad and keyboard scrolling are the platform's, so the whole mobile/tablet experience runs without JavaScript. Script is confined to the arrow controls, which need to know whether the content overflows.

That JavaScript is deletable: ::scroll-button() does exactly this in CSS, including disabling itself when a direction is exhausted, so the controls collapse to a few lines of CSS once it is available everywhere. As of writing it is Chrome/Edge only — Firefox and Safari both report no support — and going CSS-only today would also put the arrow glyph outside the semantic icon map and its accessible name outside next-intl.

The root is a plain positioned <div> with no landmark role: the caller owns the heading and its <section aria-labelledby>. That keeps this primitive domain-agnostic, and it is also what makes the controls centre on the cards rather than on "heading + cards".

Exposes data-has-overflow, data-can-scroll-prev and data-can-scroll-next so a brand can add edge fades or restyle the controls in CSS alone.

Preview

DefaultOpen in Storybook ↗

Usage

Two slides in a wide frame: everything fits, so no controls render at all and the root carries no data-has-overflow. State attributes are presence-based — data-[has-overflow]: styles the overflowing case, and the absence of the attribute is the other one. Narrow the window and the arrows appear on their own.

<Carousel>
<CarouselViewport>
{orders.map((order) => (
<CarouselItem key={order.id} className="w-[272px] lg:w-[352px]">
<OrderCard order={order} />
</CarouselItem>
))}
</CarouselViewport>
<CarouselPrevious label={t("previous")} />
<CarouselNext label={t("next")} />
</Carousel>;

Props

No props of its own.

Change the layout

Pass children to Carousel to arrange its parts yourself. Every part is exported from @evolve-storefront/ui/carousel.

<Carousel>
<CarouselViewport>
{orders.map((order) => (
<CarouselItem key={order.id} className="w-[272px] lg:w-[352px]">
<OrderCard order={order} />
</CarouselItem>
))}
</CarouselViewport>
<CarouselPrevious label={t("previous")} />
<CarouselNext label={t("next")} />
</Carousel>;
CarouselItemOne slide.

No props of its own.

CarouselNextScrolls the rail forward one step.
PropTypeDescription
type?"button" | "submit" | "reset"
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

variant?ButtonVariant
tone?ButtonTone
data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

label?string

Accessible name. Defaults to English; pass a translated string.

icon?ReactNode

Replace the glyph. Defaults to the previous / next icon.

Any other props go to the root element, like on a plain HTML element.

CarouselPreviousScrolls the rail back one step.
PropTypeDescription
type?"button" | "submit" | "reset"
isIconOnly?boolean

The button has no visible label, so it is square. Structural rather than a size: it says the button has nothing to be wide for, and says nothing about how big — the brand's control tokens still decide that.

More

Not inferred from the children. A kebab menu is as often a glyph or an SVG as an <Icon>, and a visually-hidden label — the accessible thing to add — would break any :only-child predicate silently.

variant?ButtonVariant
tone?ButtonTone
data-component?undefined

A button's identity is not a caller's to rename — see buttonProps. Typed away so a call site renaming it fails to compile rather than passing an attribute that is silently discarded.

isPending?boolean

The action is running. The button keeps its label, so its width and accessible name hold, shows a spinner and is aria-busy. It stays focusable rather than disabled, so focus is not dropped mid-save; clicks and the form submit it would trigger are ignored. Not supported with asChild.

label?string

Accessible name. Defaults to English; pass a translated string.

icon?ReactNode

Replace the glyph. Defaults to the previous / next icon.

Any other props go to the root element, like on a plain HTML element.

CarouselViewportThe scroll container.

role="list" is load-bearing rather than noise: Tailwind's preflight sets list-style: none, and WebKit drops list semantics from such lists, so VoiceOver would not otherwise announce the list or its item count.

PropTypeDescription
fullBleed?boolean

Let the rail run to the screen edge below lg while its first item stays aligned with the page gutter.

More

Bleeds by --container-padding, so it assumes the carousel sits inside Container and stops bleeding at lg, which is where the designs put the rail back inside the content column. An ancestor with overflow: hidden will clip it.

Any other props go to the root element, like on a plain HTML element.

Examples

OverflowingEnough slides to overflow.
// Nothing to configure: the arrows appear once the items overflow.
<Carousel>
<CarouselViewport>{items}</CarouselViewport>
<CarouselPrevious label={t("previous")} />
<CarouselNext label={t("next")} />
</Carousel>;
Open in Storybook ↗
Full BleedInside a Container, fullBleed lets the rail run to the screen edge below lg while the first slide stays aligned with the page gutter — the mobile treatment in the designs.
<Container>
<Carousel>
<CarouselViewport fullBleed>…</CarouselViewport>
<CarouselPrevious label={t("previous")} />
<CarouselNext label={t("next")} />
</Carousel>
</Container>;
Open in Storybook ↗
Loaded LaterSlides that arrive from a one-time client-side fetch.
// The controls appear once the client-side query fills the rail.
const { data: orders = [] } = useQuery(recentOrdersQueryOptions({ gqlClient }));
<Carousel>
<CarouselViewport>
{orders.map((order) => <CarouselItem key={order.id}>…</CarouselItem>)}
</CarouselViewport>
…
</Carousel>;
Open in Storybook ↗
Many ItemsNarrow slides, to feel the free-swipe behaviour that proximity snapping preserves and mandatory snapping would amputate.
// The slide width is the caller's: narrow tiles are just a narrower class.
<CarouselItem key={tile.id} className="w-32">
<ProductTile product={tile} />
</CarouselItem>;
Open in Storybook ↗