import { Carousel } from '@coinbase/cds-web/carousel/Carousel'
Basics
Carousels are a great way to showcase a list of items in a compact and engaging way.
By default, Carousels have navigation and pagination enabled.
You can also add a title to the Carousel by setting title prop.
You simply wrap each child in a CarouselItem component, and can optionally set the width prop to control the width of the item.
You can also set the styles prop to control the styles of the carousel, such as the gap between items.
Images inside of the carousel have pointer-events disabled by default.
function MyCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel loop paginationVariant="dot" title="Explore Assets" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Item Sizing
Items by default take their natural width while in the carousel, such as from our example above.
However, you can set the width prop of CarouselItem to control the width of the item.
Dynamic Sizing
Items can be given a width proportional to the carousel width.
If you have a gap between items, you should account for that in the width.
For example, if you have a gap of 16px, and you want to show 2 items per page,
you should give each item a width of calc(50% - 8px).
function DynamicSizingCarousel() { const itemsPerPage = [ { id: 'one', label: 'One' }, { id: 'two', label: 'Two' }, { id: 'three', label: 'Three' }, ]; const [selectedItemsPerPage, setSelectedItemsPerPage] = useState(itemsPerPage[0]); const itemWidths = { one: '100%', two: 'calc((100% - var(--space-2)) / 2)', three: 'calc((100% - (2 * var(--space-2))) / 3)', }; function NoopFn() { console.log('pressed'); } function ActionButton({ isVisible, children }) { return ( <Button compact flush="start" numberOfLines={1} onClick={NoopFn} variant="secondary"> {children} </Button> ); } return ( <VStack gap={2}> <HStack justifyContent="flex-end" gap={2} alignItems="center"> <Text as="h3" font="headline"> Items per page </Text> <SegmentedTabs activeTab={selectedItemsPerPage} onChange={setSelectedItemsPerPage} tabs={itemsPerPage} /> </HStack> <Box marginX={-3}> <Carousel hidePagination title="Learn more" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} key={selectedItemsPerPage.id} > <CarouselItem id="recurring-buy" width={itemWidths[selectedItemsPerPage.id]} accessibilityLabelledBy="recurring-buy-label" > {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get started</ActionButton>} description="Want to add funds to your card every week or month?" media={ <Box bottom={6} position="relative" right={24}> <Pictogram dimension="64x64" name="recurringPurchases" /> </Box> } minWidth="0" title={ <Text as="h3" font="headline" id="recurring-buy-label"> Recurring Buy </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="eths-apr" width={itemWidths[selectedItemsPerPage.id]} accessibilityLabelledBy="eths-apr-label" > {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Start earning</ActionButton>} dangerouslySetBackground="rgb(var(--purple70))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Earn staking rewards on ETH by holding it on Coinbase </Text> } media={ <Box left={16} position="relative" top={12}> <RemoteImage height={174} source="/img/feature.png" /> </Box> } minWidth="0" title={ <Text id="eths-apr-label" color="fgInverse" as="h3"> Up to 3.29% APR on ETHs </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="join-the-community" width={itemWidths[selectedItemsPerPage.id]} accessibilityLabelledBy="join-the-community-label" > {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Start chatting</ActionButton>} dangerouslySetBackground="rgb(var(--teal70))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Chat with other devs in our Discord community </Text> } media={ <Box left={16} position="relative" top={4}> <RemoteImage height={174} source="/img/community.png" /> </Box> } minWidth="0" title={ <Text id="join-the-community-label" color="fgInverse" as="h3"> Join the community </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="coinbase-one-offer" width={itemWidths[selectedItemsPerPage.id]} accessibilityLabelledBy="coinbase-one-offer-label" > {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get 60 days free</ActionButton>} dangerouslySetBackground="rgb(var(--blue80))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Use code NOV60 when you sign up for Coinbase One </Text> } media={ <Box left={16} position="relative" top={0}> <RemoteImage height={174} source="/img/marketing.png" /> </Box> } minWidth="0" title={ <Text id="coinbase-one-offer-label" color="fgInverse" as="h3"> Coinbase One offer </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="coinbase-card" width={itemWidths[selectedItemsPerPage.id]} accessibilityLabelledBy="coinbase-card-label" > {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get started</ActionButton>} dangerouslySetBackground="rgb(var(--gray100))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Spend USDC to get rewards with our Visa® debit card </Text> } media={ <Box left={16} position="relative" top={0}> <RemoteImage height={174} source="/img/object.png" /> </Box> } minWidth="0" title={ <Text id="coinbase-card-label" color="fgInverse" as="h3"> Coinbase Card </Text> } width="100%" /> )} </CarouselItem> </Carousel> </Box> </VStack> ); }
Responsive Sizing
You can also use responsive props to change the number of items visible based on the carousel width. The carousel below will show per page 1 item on mobile, 2 items on tablet, and 3 items on desktop.
function ResponsiveSizingCarousel() { const itemWidth = { phone: '100%', tablet: 'calc((100% - var(--space-2)) / 2)', desktop: 'calc((100% - (2 * var(--space-2))) / 3)', }; function NoopFn() { console.log('pressed'); } function ActionButton({ isVisible, children }) { return ( <Pressable background="transparent" onClick={NoopFn} paddingY={1} tabIndex={isVisible ? undefined : -1} borderRadius={500} > <Text color="fgPrimary" font="headline" numberOfLines={1}> {children} </Text> </Pressable> ); } return ( <Box marginX={-3}> <Carousel hidePagination title="Learn more" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' } }} drag="free" > <CarouselItem id="earn-more-crypto" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Earn more crypto" description="You've got unstaked crypto." pictogram="key" action={<ActionButton isVisible={isVisible}>Start earning</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> <CarouselItem id="secure-your-account" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Secure your account" description="Add two-factor authentication." pictogram="shield" action={<ActionButton isVisible={isVisible}>Enable 2FA</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> <CarouselItem id="complete-your-profile" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Complete your profile" description="Add more details." pictogram="accountsNavigation" action={<ActionButton isVisible={isVisible}>Update</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> </Carousel> </Box> ); }
Varied Sizing
Not all carousel items need to be the same size. You can provide CarouselItems of varying widths as well.
function VariedSizingCarousel() { function SquareAssetCard({ imageUrl, name }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} subtitle={name} title="$0.87" /> ); } function NoopFn() { console.log('pressed'); } function ActionButton({ isVisible, children }) { return ( <Pressable background="transparent" onClick={NoopFn} paddingY={1} tabIndex={isVisible ? undefined : -1} borderRadius={500} > <Text color="fgPrimary" font="headline" numberOfLines={1}> {children} </Text> </Pressable> ); } const itemWidth = { phone: '100%', tablet: 'calc((100% - var(--space-2)) / 2)', desktop: 'calc((100% - var(--space-2)) / 2)', }; return ( <Box marginX={-3}> <Carousel hidePagination title="Varied Sizing" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' } }} > <CarouselItem id="earn-more-crypto" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Earn more crypto" description="You've got unstaked crypto. Stake it now to earn more." pictogram="key" action={<ActionButton isVisible={isVisible}>Start earning</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> <CarouselItem id="btc"> <SquareAssetCard imageUrl={assets.btc.imageUrl} name="BTC" /> </CarouselItem> <CarouselItem id="secure-your-account" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Secure your account" description="Add two-factor authentication for enhanced security." pictogram="shield" action={<ActionButton isVisible={isVisible}>Enable 2FA</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> <CarouselItem id="eth"> <SquareAssetCard imageUrl={assets.eth.imageUrl} name="ETH" /> </CarouselItem> <CarouselItem id="complete-your-profile" width={itemWidth}> {({ isVisible }) => ( <NudgeCard title="Complete your profile" description="Add more details to personalize your experience." pictogram="accountsNavigation" action={<ActionButton isVisible={isVisible}>Update profile</ActionButton>} width="100%" minWidth="0" /> )} </CarouselItem> <CarouselItem id="ltc"> <SquareAssetCard imageUrl={assets.ltc.imageUrl} name="LTC" /> </CarouselItem> </Carousel> </Box> ); }
Drag
You can set the drag prop to snap (default), free, or none.
When set to snap, upon release the carousel will snap to either the nearest item or page (depending on snapMode).
function DragCarousel() { const toast = useToast(); const dragOptions = [ { id: 'snap', label: 'Snap' }, { id: 'free', label: 'Free' }, { id: 'none', label: 'None' }, ]; const [drag, setDrag] = useState(dragOptions[0]); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <VStack gap={2}> <HStack justifyContent="flex-end" gap={2} alignItems="center"> <Text as="h3" font="headline"> Drag </Text> <SegmentedTabs activeTab={drag} onChange={setDrag} tabs={dragOptions} /> </HStack> <Box marginX={-3}> <Carousel title="Explore Assets" hidePagination drag={drag.id} styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} snapMode="item" key={drag.id} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> </VStack> ); }
Snap Mode
You can set the snapMode to page (default) or item.
When set to page, the carousel will automatically group items into pages.
When set to item, the carousel will snap to the nearest item.
function SnapModeCarousel() { const toast = useToast(); const snapModeOptions = [ { id: 'page', label: 'Page' }, { id: 'item', label: 'Item' }, ]; const [snapMode, setSnapMode] = useState(snapModeOptions[0]); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <VStack gap={2}> <HStack justifyContent="flex-end" gap={2} alignItems="center"> <Text as="h3" font="headline"> Snap mode </Text> <SegmentedTabs activeTab={snapMode} onChange={setSnapMode} tabs={snapModeOptions} /> </HStack> <Box marginX={-3}> <Carousel title="Explore Assets" paginationVariant="dot" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} snapMode={snapMode.id} key={snapMode.id} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> </VStack> ); }
Overflow
By default, the carousel's inner overflow is visible.
This means that you can apply padding to the inner carousel element
(such as styles={{ carousel: { paddingInline: 'var(--space-3)' } }}) and it will not be clipped.
You can pair this with modifying the spacing of the inner carousel to match
the padding of your page (along with a wrapping div to negate any default spacing).
This creates a seamless experience.
If you want to have the next item be shown at the edge of the screen, make sure your carousel padding is larger than your gap.
function OverflowCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel title="Explore Assets" paginationVariant="dot" snapMode="item" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Autoplay
Use autoplay to allow for automatic page advancement. The default interval is 3 seconds but can be changed with autoplayInterval.
It is recommended to use pagination with autoplay so users know how many pages there are, and you should also set paginationVariant="dot" to best indicate the active page and progress.
function AutoplayCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel autoplay loop paginationVariant="dot" title="Trending Assets" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Looping
Use loop to allow for infinite scrolling.
function LoopingCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel autoplay loop paginationVariant="dot" snapMode="item" title="Infinite Scroll" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Accessibility
The carousel is accessible by default, and works best with interactive elements that can be focused. Users can navigate via keyboard or voiceover and will switch pages as they navigate through the carousel.
Each carousel item should have proper text within the focusable element or you use accessibilityLabel or accessibilityLabelledBy props to provide a label.
While not recommended, if your carousel has disabled drag, you can use isVisible render prop to prevent users from focusing on carousel items that are not visible.
<Carousel paginationVariant="dot" drag="none">
<CarouselItem id="btc" accessibilityLabel="Bitcoin">
<SquareAssetCard imageUrl={assets.btc.imageUrl} name={assets.btc.symbol} />
</CarouselItem>
<CarouselItem id="recurring-buy" width="100%" accessibilityLabelledBy="recurring-buy-label">
{({ isVisible }) => (
<UpsellCard
action={
<Button
compact
flush="start"
numberOfLines={1}
onClick={NoopFn}
tabIndex={isVisible ? undefined : -1}
variant="secondary"
>
Get started
</Button>
}
description="Want to add funds to your card every week or month?"
media={
<Box bottom={6} position="relative" right={24}>
<Pictogram dimension="64x64" name="recurringPurchases" />
</Box>
}
minWidth="0"
title={
<Text as="h3" font="headline" id="recurring-buy-label">
Recurring Buy
</Text>
}
width="100%"
/>
)}
</CarouselItem>
</Carousel>
Autoplay
You should note use hideNavigation and autoplay together, unless you provide an alternative pause mechanism. See this example with custom autoplay controls as an example.
Accessibility Labels
The Carousel provides several props to customize accessibility labels for screen reader users
<Carousel
nextPageAccessibilityLabel="Go to next slide"
previousPageAccessibilityLabel="Go to previous slide"
paginationAccessibilityLabel={(pageIndex) => `Go to page ${pageIndex + 1}`}
autoplayAccessibilityLabel="Play/pause carousel"
pageChangeAccessibilityLabel={(activePageIndex, totalPages) =>
`Showing page ${activePageIndex + 1} of ${totalPages}`
}
>
...
</Carousel>
Customization
Custom Components
You can customize the navigation and pagination components of the carousel using the NavigationComponent and PaginationComponent props. You can also modify the title by providing a ReactNode for the title prop.
function CustomComponentsCarousel() { function SeeAllComponent({ style }) { return ( <Text style={style}> <Link openInNewWindow href="https://coinbase.com/"> See all </Link> </Text> ); } function PaginationComponent({ totalPages, activePageIndex, onClickPage, style }) { const canGoPrevious = activePageIndex > 0; const canGoNext = activePageIndex < totalPages - 1; const dotStyles = { width: 'var(--space-2)', height: 'var(--space-2)', borderRadius: 'var(--borderRadius-1000)', } as const; function onPrevious() { onClickPage(activePageIndex - 1); } function onNext() { onClickPage(activePageIndex + 1); } return ( <HStack justifyContent="space-between" paddingY={0.5} style={style}> <HStack gap={1}> <IconButton accessibilityLabel="Previous" disabled={!canGoPrevious} name="caretLeft" onClick={onPrevious} variant="foregroundMuted" /> <IconButton accessibilityLabel="Next" disabled={!canGoNext} name="caretRight" onClick={onNext} variant="foregroundMuted" /> </HStack> <HStack alignItems="center" gap={1}> {Array.from({ length: totalPages }, (_, index) => ( <Pressable key={index} accessibilityLabel={`Go to page ${index + 1}`} background={index === activePageIndex ? 'bgPrimary' : 'bgSecondary'} borderColor={index === activePageIndex ? 'fgPrimary' : 'bgLine'} data-testid={`carousel-page-${index}`} onClick={() => onClickPage(index)} style={dotStyles} /> ))} </HStack> </HStack> ); } function NoopFn() { console.log('pressed'); } function ActionButton({ isVisible, children }) { return ( <Button compact flush="start" numberOfLines={1} onClick={NoopFn} tabIndex={isVisible ? undefined : -1} variant="secondary" > {children} </Button> ); } const itemWidth = { phone: '100%', tablet: 'round(down, calc((100% - var(--space-2)) / 2), 1px)', desktop: 'round(down, calc((100% - var(--space-2)) / 2), 1px)', }; return ( <Box marginX={-3}> <Carousel NavigationComponent={SeeAllComponent} PaginationComponent={PaginationComponent} styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} title={ <Text as="h3" font="headline"> Learn more </Text> } > <CarouselItem id="recurring-buy" width={itemWidth}> {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get started</ActionButton>} description="Want to add funds to your card every week or month?" media={ <Box bottom={6} position="relative" right={24}> <Pictogram dimension="64x64" name="recurringPurchases" /> </Box> } minWidth="0" title="Recurring Buy" width="100%" /> )} </CarouselItem> <CarouselItem id="eths-apr" width={itemWidth}> {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Start earning</ActionButton>} dangerouslySetBackground="rgb(var(--purple70))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Earn staking rewards on ETH by holding it on Coinbase </Text> } media={ <Box left={16} position="relative" top={12}> <RemoteImage height={174} source="/img/feature.png" /> </Box> } minWidth="0" title={ <Text color="fgInverse" as="h3" font="headline"> Up to 3.29% APR on ETHs </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="join-the-community" width={itemWidth}> {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Start chatting</ActionButton>} dangerouslySetBackground="rgb(var(--teal70))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Chat with other devs in our Discord community </Text> } media={ <Box left={16} position="relative" top={4}> <RemoteImage height={174} source="/img/community.png" /> </Box> } minWidth="0" title={ <Text color="fgInverse" as="h3" font="headline"> Join the community </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="coinbase-one-offer" width={itemWidth}> {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get 60 days free</ActionButton>} dangerouslySetBackground="rgb(var(--blue80))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Use code NOV60 when you sign up for Coinbase One </Text> } media={ <Box left={16} position="relative" top={0}> <RemoteImage height={174} source="/img/marketing.png" /> </Box> } minWidth="0" title={ <Text color="fgInverse" as="h3" font="headline"> Coinbase One offer </Text> } width="100%" /> )} </CarouselItem> <CarouselItem id="coinbase-card" width={itemWidth}> {({ isVisible }) => ( <UpsellCard action={<ActionButton isVisible={isVisible}>Get started</ActionButton>} dangerouslySetBackground="rgb(var(--gray100))" description={ <Text as="p" font="label2" numberOfLines={3} color="fgInverse"> Spend USDC to get rewards with our Visa® debit card </Text> } media={ <Box left={16} position="relative" top={0}> <RemoteImage height={174} source="/img/object.png" /> </Box> } minWidth="0" title={ <Text color="fgInverse" as="h3" font="headline"> Coinbase Card </Text> } width="100%" /> )} </CarouselItem> </Carousel> </Box> ); }
Custom Styles
You can use the classNames and styles props to customize different parts of the carousel.
function CustomStylesCarousel() { return ( <Box marginX={-3}> <Carousel paginationVariant="dot" styles={{ root: { position: 'relative', paddingInline: 'var(--space-6)' }, carousel: { gap: 'var(--space-6)' }, }} NavigationComponent={({ className, style, disableGoNext, disableGoPrevious, nextPageAccessibilityLabel, onGoNext, onGoPrevious, previousPageAccessibilityLabel, }) => { return ( <DefaultCarouselNavigation className={className} disableGoNext={disableGoNext} disableGoPrevious={disableGoPrevious} nextPageAccessibilityLabel={nextPageAccessibilityLabel} onGoNext={onGoNext} onGoPrevious={onGoPrevious} previousPageAccessibilityLabel={previousPageAccessibilityLabel} style={style} styles={{ previousButton: { position: 'absolute', top: 'var(--space-8)', zIndex: 1, left: 'var(--space-0_5)', }, nextButton: { position: 'absolute', top: 'var(--space-8)', zIndex: 1, right: 'var(--space-0_5)', }, }} /> ); }} > <CarouselItem id="earn-more-crypto" width="100%"> <NudgeCard title="Earn more crypto" description="You've got unstaked crypto. Stake it now to earn more." pictogram="key" action="Start earning" onActionPress={() => console.log('Action pressed')} width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="secure-your-account" width="100%"> <NudgeCard title="Secure your account" description="Add two-factor authentication for enhanced security." pictogram="shield" action="Enable 2FA" onActionPress={() => console.log('Enable 2FA pressed')} width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="complete-your-profile" width="100%"> <NudgeCard title="Complete your profile" description="Add more details to personalize your experience." pictogram="accountsNavigation" action="Update profile" onActionPress={() => console.log('Update profile pressed')} width="100%" minWidth="0" /> </CarouselItem> </Carousel> </Box> ); }
Custom Autoplay Controls
You can use useCarouselAutoplayContext inside a custom PaginationComponent to build your own controls. This example shows a composed layout with pagination and play/pause on the left, and navigation arrows on the right.
function ComposedAutoplayCarousel() { const carouselRef = useRef(null); function CustomPaginationDots({ totalPages, activePageIndex, onClickPage }) { const autoplay = useCarouselAutoplayContext(); return ( <HStack alignItems="center" background="bgSecondary" borderRadius={1000} gap={0.5} paddingX={1.5} style={{ height: 40 }} > {Array.from({ length: totalPages }, (_, index) => { const isActive = index === activePageIndex; const showProgress = isActive && autoplay.isEnabled; // Calculate progress from timing info const remainingTime = autoplay.getRemainingTime(); const progress = 1 - remainingTime / autoplay.totalTime; const progressDuration = autoplay.isPlaying ? remainingTime / 1000 : 0; return ( <Pressable key={index} accessibilityLabel={`Go to page ${index + 1}`} background={isActive && !showProgress ? 'fgPrimary' : 'bgTertiary'} borderRadius={1000} borderWidth={0} onClick={() => onClickPage?.(index)} style={{ height: 8, width: isActive ? 24 : 8, transition: 'width 0.2s ease', overflow: 'hidden', }} > {showProgress && ( <m.div animate={{ width: autoplay.isPlaying ? '100%' : `${progress * 100}%` }} initial={{ width: '0%' }} transition={{ duration: progressDuration, ease: 'linear' }} style={{ height: '100%', background: 'var(--color-fgPrimary)', borderRadius: 'var(--borderRadius-1000)', }} /> )} </Pressable> ); })} </HStack> ); } function CustomControls({ totalPages, activePageIndex, onClickPage }) { const autoplay = useCarouselAutoplayContext(); return ( <HStack justifyContent="space-between" paddingY={1}> <HStack gap={1} alignItems="center"> <CustomPaginationDots totalPages={totalPages} activePageIndex={activePageIndex} onClickPage={onClickPage} /> <IconButton accessibilityLabel={'Play/Pause Carousel'} name={autoplay.isStopped ? 'play' : 'pause'} onClick={autoplay.toggle} variant="secondary" /> </HStack> <HStack gap={1}> <IconButton accessibilityLabel="Previous" disabled={activePageIndex <= 0} name="caretLeft" onClick={() => carouselRef.current?.goToPage(activePageIndex - 1)} variant="secondary" /> <IconButton accessibilityLabel="Next" disabled={activePageIndex >= totalPages - 1} name="caretRight" onClick={() => carouselRef.current?.goToPage(activePageIndex + 1)} variant="secondary" /> </HStack> </HStack> ); } return ( <Box marginX={-3}> <Carousel ref={carouselRef} autoplay loop hideNavigation PaginationComponent={CustomControls} styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > <CarouselItem id="innovation-1" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="shield" width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="innovation-2" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="security" width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="innovation-3" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="institutions" width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="innovation-4" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="key" width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="innovation-5" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="receipt" width="100%" minWidth="0" /> </CarouselItem> <CarouselItem id="innovation-6" width="calc((100% - 2 * var(--space-2)) / 3)"> <NudgeCard title="Innovation" description="Cards are a great way to showcase content." pictogram="worldwide" width="100%" minWidth="0" /> </CarouselItem> </Carousel> </Box> ); }
Dynamic Content
You can dynamically add and remove items from the carousel.
function DynamicContentCarousel() { const toast = useToast(); const [items, setItems] = useState(Object.values(assets).slice(0, 3)); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } function addAsset() { const randomAsset = Object.values(assets)[Math.floor(Math.random() * Object.values(assets).length)]; setItems([...items, { ...randomAsset, symbol: `${randomAsset.symbol}-${items.length}` }]); } return ( <VStack gap={2}> <HStack justifyContent="flex-end" gap={2} alignItems="center"> <Button compact onClick={addAsset}> Add Asset </Button> <Button compact onClick={() => setItems(items.slice(0, -1))} disabled={items.length === 0}> Remove Last </Button> </HStack> <Box marginX={-3}> <Carousel title="Explore Assets" paginationVariant="dot" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)', height: '156px' }, }} > {items.map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> </VStack> ); }
You can also animate items as they enter or leave the viewport.
function AnimatedCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { const ref = useRef(null); // useInView is a framer motion hook that detects when an element is in the viewport const isInView = useInView(ref, { amount: 0.5, once: false, }); return ( <motion.div ref={ref} initial={{ scale: 1 }} animate={{ scale: isInView ? 1 : 0.8, }} > <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> </motion.div> ); } return ( <Box marginX={-3}> <Carousel title="Explore Assets" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} snapMode="item" hidePagination > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
You can even change the size or content of items. In the example below, click an asset to highlight it.
function AnimatedSelectionCarousel() { const dimensions = { width: 140, height: 60 }; const path = useSparklinePath({ ...dimensions, data: prices }); const SquareAssetCard = memo(({ isVisible, imageUrl, name, color }) => { const squareSize = 156; const largeSize = 327; const [isHighlighted, setIsHighlighted] = useState(false); const [size, setSize] = useState('s'); const handleClick = useCallback(() => { setIsHighlighted((highlighted) => !highlighted); }, [setIsHighlighted]); const onAnimationStart = useCallback(() => { if (isHighlighted) { setSize('l'); } }, [isHighlighted, setSize]); const onAnimationComplete = useCallback(() => { if (!isHighlighted) { setSize('s'); } }, [isHighlighted, setSize]); return ( <Pressable onClick={handleClick} tabIndex={isVisible ? undefined : -1} borderRadius={500}> <motion.div initial={{ width: squareSize }} animate={{ width: isHighlighted ? largeSize : squareSize, }} onAnimationStart={onAnimationStart} onAnimationComplete={onAnimationComplete} style={{ overflow: 'hidden', borderRadius: 'var(--borderRadius-500)', }} > <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} subtitle={name} title="$0.87" style={{ maxWidth: largeSize, width: '100%' }} size={size} > <VStack padding={1} justifyContent="center" height={squareSize}> <Sparkline {...dimensions} path={path} color={color} /> </VStack> </ContainedAssetCard> </motion.div> </Pressable> ); }); return ( <Box marginX={-3}> <Carousel title="Explore Assets" paginationVariant="dot" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol}> {({ isVisible }) => ( <SquareAssetCard isVisible={isVisible} imageUrl={asset.imageUrl} name={asset.symbol} color={asset.color} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Hide Navigation and Pagination
You can hide the navigation and pagination components of the carousel if desired (using hideNavigation and hidePagination props).
Note that this can prevent proper accessibility for the carousel, if carousel items are not focusable. If hiding pagination, it's recommended instead to
pass in DefaultCarouselNavigation with hideUnlessFocused prop. Alternatively, you can ensure that the carousel is
navigable by keyboard through other means.
function HideNavigationAndPaginationCarousel() { const toast = useToast(); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel title="Explore Assets" hidePagination NavigationComponent={(props) => <DefaultCarouselNavigation {...props} hideUnlessFocused />} drag="free" snapMode="item" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Animated Pagination
You can create smooth pagination animations by customizing the pagination dots. This example shows how to create expanding dots that smoothly transition between active and inactive states.
function AnimatedPaginationCarousel() { const toast = useToast(); const AnimatedPagination = memo((props) => { const { totalPages, activePageIndex, onClickPage, style } = props; const dotStyles = { height: 'var(--space-1)', borderRadius: 'var(--borderRadius-1000)', transition: 'all 0.3s ease', cursor: 'pointer', }; return ( <HStack alignItems="center" gap={0.5} justifyContent="center" style={style}> {Array.from({ length: totalPages }, (_, index) => { const isActive = index === activePageIndex; return ( <Pressable key={index} accessibilityLabel={`Go to page ${index + 1}`} background={isActive ? 'bgPrimary' : 'bgLine'} borderWidth={0} data-testid={`carousel-page-${index}`} onClick={() => onClickPage?.(index)} style={{ ...dotStyles, width: isActive ? 'var(--space-3)' : 'var(--space-1)', }} /> ); })} </HStack> ); }); function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <Box marginX={-3}> <Carousel PaginationComponent={AnimatedPagination} drag="snap" snapMode="page" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} title="Explore Assets" > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> ); }
Imperative API
You can control the carousel programmatically using a ref. The carousel exposes methods to navigate to specific pages and access the current page index.
function ImperativeApiCarousel() { const toast = useToast(); const carouselRef = useRef(null); const [currentPageInfo, setCurrentPageInfo] = useState('Page 1'); function handleGoToPage(pageIndex: number) { if (carouselRef.current) { const clampedPageIndex = Math.max(0, Math.min(carouselRef.current.totalPages - 1, pageIndex)); carouselRef.current.goToPage(clampedPageIndex); setCurrentPageInfo(`Page ${clampedPageIndex + 1}`); } } function handleGoToFirstPage() { handleGoToPage(0); } function handleGoToLastPage() { if (carouselRef.current) { handleGoToPage(carouselRef.current.totalPages - 1); } } function handleGoToPrevPage() { if (carouselRef.current) { handleGoToPage(carouselRef.current.activePageIndex - 1); } } function handleGoToNextPage() { if (carouselRef.current) { handleGoToPage(carouselRef.current.activePageIndex + 1); } } function SquareAssetCard({ imageUrl, name, onClick }) { return ( <ContainedAssetCard description={ <Text as="p" font="label2" color="fgPositive" numberOfLines={2}> ↗6.37% </Text> } header={<RemoteImage height="32px" source={imageUrl} width="32px" />} onClick={onClick} subtitle={name} title="$0.87" /> ); } return ( <VStack gap={2}> <HStack gap={2} style={{ flexWrap: 'wrap' }} justifyContent="space-between"> <HStack gap={1}> <IconButton onClick={handleGoToFirstPage} variant="secondary" name="doubleChevronRight" style={{ transform: 'rotate(180deg)' }} accessibilityLabel="Go to first page" /> <IconButton onClick={handleGoToPrevPage} variant="secondary" name="arrowLeft" active accessibilityLabel="Go to previous page" /> </HStack> <Box alignItems="center" background="bgSecondary" borderRadius={500} flexGrow={1} justifyContent="center" paddingX={2} paddingY={1} > <Text color="fgMuted" font="label1"> {currentPageInfo} </Text> </Box> <HStack gap={1}> <IconButton onClick={handleGoToNextPage} variant="secondary" name="arrowRight" active accessibilityLabel="Go to next page" /> <IconButton onClick={handleGoToLastPage} variant="secondary" name="doubleChevronRight" accessibilityLabel="Go to last page" /> </HStack> </HStack> <Box marginX={-3}> <Carousel ref={carouselRef} hidePagination hideNavigation drag="none" snapMode="item" styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-2)' }, }} title="Explore Assets" > {Object.values(assets).map((asset, index) => ( <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}> {({ isVisible }) => ( <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} onClick={() => toast.show(`${asset.symbol} clicked`)} /> )} </CarouselItem> ))} </Carousel> </Box> </VStack> ); }
Callbacks
You can use the onChangePage, onDragStart, and onDragEnd callbacks to listen for user interaction in the carousel.
<Carousel
paginationVariant="dot"
onChangePage={(pageIndex: number) => console.log('Page changed', activePageIndex)}
onDragStart={() => console.log('Drag started')}
onDragEnd={() => console.log('Drag ended')}
>
...
</Carousel>