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
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.
| Prop | Type | Description |
|---|---|---|
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. MoreNot inferred from the children. A kebab menu is as often a glyph or an SVG
as an |
variant? | ButtonVariant | |
tone? | ButtonTone | |
data-component? | undefined | A button's identity is not a caller's to rename — see |
isPending? | boolean | The action is running. The button keeps its label, so its width and
accessible name hold, shows a spinner and is |
label? | string | Accessible name. Defaults to English; pass a translated string. |
icon? | ReactNode | Replace the glyph. Defaults to the |
Any other props go to the root element, like on a plain HTML element.
CarouselPreviousScrolls the rail back one step.
| Prop | Type | Description |
|---|---|---|
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. MoreNot inferred from the children. A kebab menu is as often a glyph or an SVG
as an |
variant? | ButtonVariant | |
tone? | ButtonTone | |
data-component? | undefined | A button's identity is not a caller's to rename — see |
isPending? | boolean | The action is running. The button keeps its label, so its width and
accessible name hold, shows a spinner and is |
label? | string | Accessible name. Defaults to English; pass a translated string. |
icon? | ReactNode | Replace the glyph. Defaults to the |
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.
| Prop | Type | Description |
|---|---|---|
fullBleed? | boolean | Let the rail run to the screen edge below MoreBleeds by |
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>;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>;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>;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>;
