All components
Carousel
Sliding panels with previous and next controls and clickable indicator dots.
1Mountains
2Coastline
3Forest
12345678910111213141516171819202122232425262728293031import { Carousel } from "#components/ui/base-ui/carousel.tsx";import { indigoPalette } from "@stylexblocks/ui/themes/palettes.ts";
const slides = ["Mountains", "Coastline", "Forest"];
export function Example() { return ( // Base UI ships no carousel machine — Root owns a small controlled page // index (slideCount, defaultPage, page, onPageChange) and the parts are // composed with useRender: only the active Item shows, triggers disable // at the ends, dots select directly, and everything accepts `render` <Carousel.Root slideCount={slides.length} palette={indigoPalette}> <Carousel.ItemGroup> {slides.map((label, index) => ( <Carousel.Item key={label} index={index}> {label} </Carousel.Item> ))} </Carousel.ItemGroup> <Carousel.Control> <Carousel.PrevTrigger>‹</Carousel.PrevTrigger> <Carousel.IndicatorGroup> {slides.map((label, index) => ( <Carousel.Indicator key={label} index={index} /> ))} </Carousel.IndicatorGroup> <Carousel.NextTrigger>›</Carousel.NextTrigger> </Carousel.Control> </Carousel.Root> );}Install
1npx stylexblocks add carouselSource
components/ui/base-ui/carousel.tsx
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267import * as stylex from "@stylexjs/stylex";import { createContext, useContext, useMemo, useState } from "react";import { mergeProps } from "@base-ui-components/react/merge-props";import { useRender } from "@base-ui-components/react/use-render";import type { StyleOverrideProps } from "#lib/recipe-props.ts";import { colorPalette } from '@stylexblocks/ui/color-palette.stylex.ts'import { cursor } from '@stylexblocks/ui/tokens/cursor.stylex.ts'import { radii } from '@stylexblocks/ui/tokens/radii.stylex.ts'import { sizes } from '@stylexblocks/ui/tokens/sizes.stylex.ts'import { spacing } from '@stylexblocks/ui/tokens/spacing.stylex.ts'
const carouselRoot = stylex.create({ base: { position: 'relative', display: 'flex', gap: spacing._2, },})
const carouselItem = stylex.create({ base: {},})
const carouselControl = stylex.create({ base: { display: 'flex', alignItems: 'center', },})
const carouselIndicatorGroup = stylex.create({ base: { display: 'flex', justifyContent: 'center', gap: spacing._3, },})
const carouselIndicator = stylex.create({ base: { width: sizes._2_5, height: sizes._2_5, borderRadius: radii.full, backgroundColor: colorPalette.subtle, cursor: cursor.button, },})
// The recipe styles the shell; stacking direction, full-width slides, and// the active-dot accent are the component's own structural styles.const carouselLocal = stylex.create({ root: { flexDirection: "column" }, itemGroup: { width: "100%" }, indicatorCurrent: { backgroundColor: colorPalette.solid },});
/** * Carousel — Base UI ships no carousel machine, so the recipe is composed * through Base UI's render primitives (`useRender` + `mergeProps`) with a * small controlled page index: Root owns the state * (`slideCount`, `defaultPage`, `page`, `onPageChange`) and shares it via * context; Item shows only the active slide, the triggers page and disable * at the ends, and indicator dots select directly. Every part composes via * `render`. */interface CarouselState { page: number; slideCount: number; setPage: (page: number) => void;}
const CarouselStateContext = createContext<CarouselState>({ page: 0, slideCount: 0, setPage: () => {},});
// recipe maps hold stylex.props-compatible values by construction; one cast// bridges StyleX's variadic genericsfunction sx( styles: ReadonlyArray<unknown>, palette: StyleOverrideProps["palette"], style: StyleOverrideProps["style"],) { return stylex.props( ...([palette, ...styles, style] as unknown as ReadonlyArray<never>), );}
export type CarouselRootProps = Omit<useRender.ComponentProps<"div">, "style"> & StyleOverrideProps & { /** Total number of slides. */ slideCount: number; /** Controlled page index. */ page?: number; /** Initial page index when uncontrolled. */ defaultPage?: number; onPageChange?: (page: number) => void; };
function Root(allProps: CarouselRootProps) { const { slideCount, page, defaultPage, onPageChange, render, palette, style, ...rest } = allProps as CarouselRootProps & Record<string, unknown>; const [innerPage, setInnerPage] = useState(defaultPage ?? 0); const current = page ?? innerPage; const state = useMemo<CarouselState>( () => ({ page: current, slideCount, setPage: (next) => { const clamped = Math.min(Math.max(next, 0), slideCount - 1); if (page === undefined) setInnerPage(clamped); onPageChange?.(clamped); }, }), [current, slideCount, page, onPageChange], ); const element = useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps<"div">( { role: "region", "aria-roledescription": "carousel", ...sx([carouselRoot.base, carouselLocal.root], palette as never, style as never), }, rest, ), }); return ( <CarouselStateContext.Provider value={state}> {element} </CarouselStateContext.Provider> );}Root.displayName = "CarouselRoot";
/** A plain styled part (no machine behavior of its own). */function createPart<T extends keyof React.JSX.IntrinsicElements>( tag: T, styles: ReadonlyArray<unknown>, displayName: string,) { function Part(allProps: Omit<useRender.ComponentProps<T>, "style"> & StyleOverrideProps) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; return useRender({ defaultTagName: tag, render: render as useRender.RenderProp, props: mergeProps( { ...sx(styles, palette as never, style as never) }, rest, ), }); } Part.displayName = displayName; return Part;}
export type CarouselItemProps = Omit<useRender.ComponentProps<"div">, "style"> & StyleOverrideProps & { /** This slide's index. */ index: number; };
function Item(allProps: CarouselItemProps) { const { index, render, palette, style, ...rest } = allProps as CarouselItemProps & Record<string, unknown>; const { page, slideCount } = useContext(CarouselStateContext); return useRender({ defaultTagName: "div", render: render as useRender.RenderProp, props: mergeProps<"div">( { role: "group", "aria-roledescription": "slide", "aria-label": `${index + 1} of ${slideCount}`, hidden: index !== page, ...sx([carouselItem.base], palette as never, style as never), }, rest, ), });}Item.displayName = "CarouselItem";
function createTrigger(direction: -1 | 1, label: string, displayName: string) { function Trigger( allProps: Omit<useRender.ComponentProps<"button">, "style"> & StyleOverrideProps, ) { const { render, palette, style, ...rest } = allProps as Record<string, unknown>; const { page, slideCount, setPage } = useContext(CarouselStateContext); const disabled = direction === -1 ? page === 0 : page === slideCount - 1; return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, props: mergeProps<"button">( { type: "button", "aria-label": label, disabled, onClick: () => setPage(page + direction), ...sx([], palette as never, style as never), }, rest, ), }); } Trigger.displayName = displayName; return Trigger;}
export type CarouselIndicatorProps = Omit<useRender.ComponentProps<"button">, "style"> & StyleOverrideProps & { /** The page this dot selects. */ index: number; };
function Indicator(allProps: CarouselIndicatorProps) { const { index, render, palette, style, ...rest } = allProps as CarouselIndicatorProps & Record<string, unknown>; const { page, setPage } = useContext(CarouselStateContext); const current = index === page; return useRender({ defaultTagName: "button", render: render as useRender.RenderProp, state: { current }, props: mergeProps<"button">( { type: "button", "aria-label": `Go to slide ${index + 1}`, onClick: () => setPage(index), ...sx( [carouselIndicator.base, current && carouselLocal.indicatorCurrent], palette as never, style as never, ), }, rest, ), });}Indicator.displayName = "CarouselIndicator";
export const Carousel = { Root, ItemGroup: createPart("div", [carouselLocal.itemGroup], "CarouselItemGroup"), Item, Control: createPart("div", [carouselControl.base], "CarouselControl"), PrevTrigger: createTrigger(-1, "Previous slide", "CarouselPrevTrigger"), NextTrigger: createTrigger(1, "Next slide", "CarouselNextTrigger"), IndicatorGroup: createPart( "div", [carouselIndicatorGroup.base], "CarouselIndicatorGroup", ), Indicator,};