Skip to main content
Carousel
@coinbase/cds-web@8.13.6
A flexible carousel component for displaying sequences of content with navigation and pagination options.
Import
import { Carousel } from '@coinbase/cds-web/carousel/Carousel'
SourceView source codeStorybookView StorybookFigmaView Figma

Basic Example

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

Images inside of the carousel have pointer-events disabled by default.

Loading...
Live Code
function MyCarousel() {
  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        subtitle={name}
        title="$0.87"
      />
    );
  }
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        hidePagination
        title="Explore Assets"
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
      >
        {Object.values(assets).map((asset, index) => (
          <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}>
            <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
          </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.

Tip

If you have a gap between items, you should account for that in the width. For example, if you have a gap of 8px, and you want to show 2 items per page, you should give each item a width of calc(50% - 4px).

Loading...
Live Code
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-1)) / 2)',
    three: 'calc((100% - (2 * var(--space-1))) / 3)',
  };
  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>
    );
  }
  return (
    <VStack gap={2}>
      <HStack justifyContent="flex-end" gap={2} alignItems="center">
        <TextHeadline as="h3">Items per page</TextHeadline>
        <SegmentedTabs
          activeTab={selectedItemsPerPage}
          onChange={setSelectedItemsPerPage}
          tabs={itemsPerPage}
        />
      </HStack>
      <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
        <Carousel
          hidePagination
          title="Learn more"
          styles={{
            root: { paddingInline: 'var(--space-3)' },
            carousel: { gap: 'var(--space-1)' },
          }}
          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={
                  <TextHeadline as="h3" id="recurring-buy-label">
                    Recurring Buy
                  </TextHeadline>
                }
                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={
                  <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                    Earn staking rewards on ETH by holding it on Coinbase
                  </TextLabel2>
                }
                media={
                  <Box left={16} position="relative" top={12}>
                    <RemoteImage height={174} source="/img/feature.png" />
                  </Box>
                }
                minWidth="0"
                title={
                  <TextHeadline id="eths-apr-label" color="fgInverse" as="h3">
                    Up to 3.29% APR on ETHs
                  </TextHeadline>
                }
                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={
                  <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                    Chat with other devs in our Discord community
                  </TextLabel2>
                }
                media={
                  <Box left={16} position="relative" top={4}>
                    <RemoteImage height={174} source="/img/community.png" />
                  </Box>
                }
                minWidth="0"
                title={
                  <TextHeadline id="join-the-community-label" color="fgInverse" as="h3">
                    Join the community
                  </TextHeadline>
                }
                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={
                  <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                    Use code NOV60 when you sign up for Coinbase One
                  </TextLabel2>
                }
                media={
                  <Box left={16} position="relative" top={0}>
                    <RemoteImage height={174} source="/img/marketing.png" />
                  </Box>
                }
                minWidth="0"
                title={
                  <TextHeadline id="coinbase-one-offer-label" color="fgInverse" as="h3">
                    Coinbase One offer
                  </TextHeadline>
                }
                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={
                  <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                    Spend USDC to get rewards with our Visa® debit card
                  </TextLabel2>
                }
                media={
                  <Box left={16} position="relative" top={0}>
                    <RemoteImage height={174} source="/img/object.png" />
                  </Box>
                }
                minWidth="0"
                title={
                  <TextHeadline id="coinbase-card-label" color="fgInverse" as="h3">
                    Coinbase Card
                  </TextHeadline>
                }
                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.

Loading...
Live Code
function ResponsiveSizingCarousel() {
  const itemWidth = {
    phone: '100%',
    tablet: 'calc((100% - var(--space-1)) / 2)',
    desktop: 'calc((100% - (2 * var(--space-1))) / 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 style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        hidePagination
        title="Learn more"
        styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-1)' } }}
        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.

Loading...
Live Code
function VariedSizingCarousel() {
  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        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-1)) / 2)',
    desktop: 'calc((100% - var(--space-1)) / 2)',
  };
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        hidePagination
        title="Varied Sizing"
        styles={{ root: { paddingInline: 'var(--space-3)' }, carousel: { gap: 'var(--space-1)' } }}
      >
        <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).

Loading...
Live Code
function DragCarousel() {
  const dragOptions = [
    { id: 'snap', label: 'Snap' },
    { id: 'free', label: 'Free' },
    { id: 'none', label: 'None' },
  ];
  const [drag, setDrag] = useState(dragOptions[0]);
  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        subtitle={name}
        title="$0.87"
      />
    );
  }
  return (
    <VStack gap={2}>
      <HStack justifyContent="flex-end" gap={2} alignItems="center">
        <TextHeadline as="h3">Drag</TextHeadline>
        <SegmentedTabs activeTab={drag} onChange={setDrag} tabs={dragOptions} />
      </HStack>
      <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
        <Carousel
          title="Explore Assets"
          hidePagination
          drag={drag.id}
          styles={{
            root: { paddingInline: 'var(--space-3)' },
            carousel: { gap: 'var(--space-1)' },
          }}
          snapMode="item"
          key={drag.id}
        >
          {Object.values(assets).map((asset, index) => (
            <CarouselItem key={asset.symbol} id={asset.symbol}>
              <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
            </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.

Loading...
Live Code
function SnapModeCarousel() {
  const snapModeOptions = [
    { id: 'page', label: 'Page' },
    { id: 'item', label: 'Item' },
  ];
  const [snapMode, setSnapMode] = useState(snapModeOptions[0]);
  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        subtitle={name}
        title="$0.87"
      />
    );
  }
  return (
    <VStack gap={2}>
      <HStack justifyContent="flex-end" gap={2} alignItems="center">
        <TextHeadline as="h3">Snap mode</TextHeadline>
        <SegmentedTabs activeTab={snapMode} onChange={setSnapMode} tabs={snapModeOptions} />
      </HStack>
      <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
        <Carousel
          title="Explore Assets"
          styles={{
            root: { paddingInline: 'var(--space-3)' },
            carousel: { gap: 'var(--space-1)' },
          }}
          snapMode={snapMode.id}
          key={snapMode.id}
        >
          {Object.values(assets).map((asset, index) => (
            <CarouselItem key={asset.symbol} id={asset.symbol}>
              <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
            </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.

Tip

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.

Loading...
Live Code
function OverflowCarousel() {
  function SquareAssetCard({ isVisible, imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        onClick={isVisible ? () => console.log('clicked') : undefined}
        subtitle={name}
        title="$0.87"
      />
    );
  }
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        title="Explore Assets"
        snapMode="item"
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
      >
        {Object.values(assets).map((asset, index) => (
          <CarouselItem key={asset.symbol} id={asset.symbol}>
            {({ isVisible }) => (
              <SquareAssetCard
                isVisible={isVisible}
                imageUrl={asset.imageUrl}
                name={asset.symbol}
              />
            )}
          </CarouselItem>
        ))}
      </Carousel>
    </Box>
  );
}

Accessibility

The carousel is accessible by default.

You need to use accessibilityLabel or accessibilityLabelledBy props to provide a label for the carousel items.

If you have elements that are focusable, you can use isVisible render prop to disable focus when the item is not visible.

<Carousel>
<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={
<TextHeadline as="h3" id="recurring-buy-label">
Recurring Buy
</TextHeadline>
}
width="100%"
/>
)}
</CarouselItem>
</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.

Loading...
Live Code
function CustomComponentsCarousel() {
  function SeeAllComponent({ style }) {
    return (
      <TextHeadline style={style}>
        <Link openInNewWindow href="https://coinbase.com/">
          See all
        </Link>
      </TextHeadline>
    );
  }
  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-1)) / 2), 1px)',
    desktop: 'round(down, calc((100% - var(--space-1)) / 2), 1px)',
  };
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        NavigationComponent={SeeAllComponent}
        PaginationComponent={PaginationComponent}
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
        title={
          <TextHeadline as="h3">
            Learn more
          </TextHeadline>
        }
      >
        <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={
                <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                  Earn staking rewards on ETH by holding it on Coinbase
                </TextLabel2>
              }
              media={
                <Box left={16} position="relative" top={12}>
                  <RemoteImage height={174} source="/img/feature.png" />
                </Box>
              }
              minWidth="0"
              title={
                <TextHeadline color="fgInverse" as="h3">
                  Up to 3.29% APR on ETHs
                </TextHeadline>
              }
              width="100%"
            />
          )}
        </CarouselItem>
        <CarouselItem id="join-the-community" width={itemWidth}>
          {({ isVisible }) => (
            <UpsellCard
              action={<ActionButton isVisible={isVisible}>Start chatting</ActionButton>}
              dangerouslySetBackground="rgb(var(--teal70))"
              description={
                <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                  Chat with other devs in our Discord community
                </TextLabel2>
              }
              media={
                <Box left={16} position="relative" top={4}>
                  <RemoteImage height={174} source="/img/community.png" />
                </Box>
              }
              minWidth="0"
              title={
                <TextHeadline color="fgInverse" as="h3">
                  Join the community
                </TextHeadline>
              }
              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={
                <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                  Use code NOV60 when you  sign up for Coinbase One
                </TextLabel2>
              }
              media={
                <Box left={16} position="relative" top={0}>
                  <RemoteImage height={174} source="/img/marketing.png" />
                </Box>
              }
              minWidth="0"
              title={
                <TextHeadline color="fgInverse" as="h3">
                  Coinbase One offer
                </TextHeadline>
              }
              width="100%"
            />
          )}
        </CarouselItem>
        <CarouselItem id="coinbase-card" width={itemWidth}>
          {({ isVisible }) => (
            <UpsellCard
              action={<ActionButton isVisible={isVisible}>Get started</ActionButton>}
              dangerouslySetBackground="rgb(var(--gray100))"
              description={
                <TextLabel2 as="p" numberOfLines={3} color="fgInverse">
                  Spend USDC to get rewards with our Visa® debit card
                </TextLabel2>
              }
              media={
                <Box left={16} position="relative" top={0}>
                  <RemoteImage height={174} source="/img/object.png" />
                </Box>
              }
              minWidth="0"
              title={
                <TextHeadline color="fgInverse" as="h3">
                  Coinbase Card
                </TextHeadline>
              }
              width="100%"
            />
          )}
        </CarouselItem>
      </Carousel>
    </Box>
  );
}

Custom Styles

You can use the classNames and styles props to customize different parts of the carousel.

Loading...
Live Code
function CustomStylesCarousel() {
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        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>
  );
}

Dynamic Content

You can dynamically add and remove items from the carousel.

Loading...
Live Code
function DynamicContentCarousel() {
  const [items, setItems] = useState(Object.values(assets).slice(0, 3));

  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        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 style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
        <Carousel
          title="Explore Assets"
          styles={{
            root: { paddingInline: 'var(--space-3)' },
            carousel: { gap: 'var(--space-1)', height: '156px' },
          }}
        >
          {items.map((asset, index) => (
            <CarouselItem key={asset.symbol} id={asset.symbol}>
              <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
            </CarouselItem>
          ))}
        </Carousel>
      </Box>
    </VStack>
  );
}

You can also animate items as they enter or leave the viewport.

Loading...
Live Code
function AnimatedCarousel() {
  function SquareAssetCard({ imageUrl, name }) {
    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={
            <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
              ↗6.37%
            </TextLabel2>
          }
          header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
          subtitle={name}
          title="$0.87"
        />
      </motion.div>
    );
  }
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        title="Explore Assets"
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
        snapMode="item"
        hidePagination
      >
        {Object.values(assets).map((asset, index) => (
          <CarouselItem key={asset.symbol} id={asset.symbol}>
            <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
          </CarouselItem>
        ))}
      </Carousel>
    </Box>
  );
}

You can even change the size or content of items. In the example below, click an asset to highlight it.

Loading...
Live Code
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={
              <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
                ↗6.37%
              </TextLabel2>
            }
            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 style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        title="Explore Assets"
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
      >
        {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 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.

Loading...
Live Code
function HideNavigationAndPaginationCarousel() {
  function SquareAssetCard({ imageUrl, name }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        subtitle={name}
        title="$0.87"
      />
    );
  }
  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-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-1)' },
        }}
      >
        {Object.values(assets).map((asset, index) => (
          <CarouselItem key={asset.symbol} id={asset.symbol}>
            <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
          </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.

Loading...
Live Code
function AnimatedPaginationCarousel() {
  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 }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        subtitle={name}
        title="$0.87"
      />
    );
  }

  return (
    <Box style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
      <Carousel
        PaginationComponent={AnimatedPagination}
        drag="snap"
        snapMode="page"
        styles={{
          root: { paddingInline: 'var(--space-3)' },
          carousel: { gap: 'var(--space-1)' },
        }}
        title="Explore Assets"
      >
        {Object.values(assets).map((asset, index) => (
          <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}>
            <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
          </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.

Loading...
Live Code
function ImperativeApiCarousel() {
  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 }) {
    return (
      <ContainedAssetCard
        description={
          <TextLabel2 as="p" color="fgPositive" numberOfLines={2}>
            ↗6.37%
          </TextLabel2>
        }
        header={<RemoteImage height="32px" source={imageUrl} width="32px" />}
        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 style={{ marginInline: 'calc(-1 * var(--space-3))' }}>
        <Carousel
          ref={carouselRef}
          hidePagination
          hideNavigation
          drag="none"
          snapMode="item"
          styles={{
            root: { paddingInline: 'var(--space-3)' },
            carousel: { gap: 'var(--space-1)' },
          }}
          title="Explore Assets"
        >
          {Object.values(assets).map((asset, index) => (
            <CarouselItem key={asset.symbol} id={asset.symbol} accessibilityLabel={asset.name}>
              <SquareAssetCard imageUrl={asset.imageUrl} name={asset.symbol} />
            </CarouselItem>
          ))}
        </Carousel>
      </Box>
    </VStack>
  );
}

Callbacks

You can use the onChangePage, onDragStart, and onDragEnd callbacks to listen for user interaction in the carousel.

<Carousel
onChangePage={(pageIndex: number) => console.log('Page changed', activePageIndex)}
onDragStart={() => console.log('Drag started')}
onDragEnd={() => console.log('Drag ended')}
>
...
</Carousel>

Is this page useful?

Coinbase Design is an open-source, adaptable system of guidelines, components, and tools that aid the best practices of user interface design for crypto products.