Card Carousel

Installation

Add the following Soul components

The card-carousel component uses the carousel, skeleton, card and streamable components. Make sure you have added them to your project.

Install the following dependencies

npm install clsx lucide-react

Copy and paste the following code into your project

sections/card-carousel/index.tsx

import { clsx } from 'clsx';import { ArrowLeft, ArrowRight } from 'lucide-react';import { Stream, Streamable } from '@/vibes/soul/lib/streamable';import { Card, type CardContent, CardSkeleton } from '@/vibes/soul/primitives/card';import {  Carousel,  CarouselButtons,  CarouselContent,  CarouselItem,  CarouselScrollbar,} from '@/vibes/soul/primitives/carousel';import * as Skeleton from '@/vibes/soul/primitives/skeleton';export interface CardCarouselProps {  cards: Streamable<CardContent[]>;  aspectRatio?: '5:6' | '3:4' | '1:1';  textColorScheme?: 'light' | 'dark';  iconColorScheme?: 'light' | 'dark';  carouselColorScheme?: 'light' | 'dark';  className?: string;  emptyStateTitle?: Streamable<string>;  emptyStateSubtitle?: Streamable<string>;  scrollbarLabel?: string;  previousLabel?: string;  nextLabel?: string;  showButtons?: boolean;  showScrollbar?: boolean;  hideOverflow?: boolean;  placeholderCount?: number;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { *   --card-carousel-light-empty-title: hsl(var(--foreground)); *   --card-carousel-light-empty-subtitle: hsl(var(--contrast-500)); *   --card-carousel-dark-empty-title: hsl(var(--background)); *   --card-carousel-dark-empty-subtitle: hsl(var(--contrast-100)); * } * ``` */export function CardCarousel({  cards: streamableCards,  aspectRatio = '5:6',  textColorScheme,  iconColorScheme,  carouselColorScheme = 'light',  className,  emptyStateTitle,  emptyStateSubtitle,  scrollbarLabel,  previousLabel,  nextLabel,  showButtons = true,  showScrollbar = true,  hideOverflow,}: CardCarouselProps) {  return (    <Stream      fallback={<CardCarouselSkeleton className={className} hideOverflow={hideOverflow} />}      value={streamableCards}    >      {(cards) => {        if (cards.length === 0) {          return (            <CardCarouselEmptyState              carouselColorScheme={carouselColorScheme}              className={className}              emptyStateSubtitle={emptyStateSubtitle}              emptyStateTitle={emptyStateTitle}              hideOverflow={hideOverflow}            />          );        }        return (          <Carousel className={className} hideOverflow={hideOverflow}>            <CarouselContent>              {cards.map(({ ...card }) => (                <CarouselItem                  className="basis-full @sm:basis-1/2 @md:basis-1/3 @4xl:basis-1/4"                  key={card.href}                >                  <Card                    {...card}                    aspectRatio={aspectRatio}                    iconColorScheme={iconColorScheme}                    textColorScheme={textColorScheme}                  />                </CarouselItem>              ))}            </CarouselContent>            {(showButtons || showScrollbar) && (              <div className="mt-10 flex w-full items-center justify-between gap-8">                <CarouselScrollbar                  className={clsx(!showScrollbar && 'pointer-events-none invisible')}                  colorScheme={carouselColorScheme}                  label={scrollbarLabel}                />                <CarouselButtons                  className={clsx(!showButtons && 'pointer-events-none invisible')}                  colorScheme={carouselColorScheme}                  nextLabel={nextLabel}                  previousLabel={previousLabel}                />              </div>            )}          </Carousel>        );      }}    </Stream>  );}export function CardCarouselSkeleton({  className,  placeholderCount = 4,  hideOverflow = true,}: Pick<  CardCarouselProps,  'className' | 'emptyStateTitle' | 'emptyStateSubtitle' | 'hideOverflow' | 'placeholderCount'>) {  return (    <Skeleton.Root      className={clsx('group-has-[[data-pending]]/card-carousel:animate-pulse', className)}      hideOverflow={hideOverflow}      pending    >      <div className="w-full">        <div className="-ml-4 flex @2xl:-ml-5">          {Array.from({ length: placeholderCount }).map((_, index) => (            <div              className="min-w-0 shrink-0 grow-0 basis-full @sm:basis-1/2 @md:basis-1/3 @2xl:pl-5 @4xl:basis-1/4"              key={index}            >              <CardSkeleton />            </div>          ))}        </div>      </div>      <div className="mt-10 flex w-full items-center justify-between gap-8">        <Skeleton.Box className="h-1 w-full max-w-56 rounded" />        <div className="flex gap-2 text-contrast-200">          <Skeleton.Icon icon={<ArrowLeft aria-hidden className="h-6 w-6" strokeWidth={1.5} />} />          <Skeleton.Icon icon={<ArrowRight aria-hidden className="h-6 w-6" strokeWidth={1.5} />} />        </div>      </div>    </Skeleton.Root>  );}export function CardCarouselEmptyState({  className,  placeholderCount = 4,  emptyStateTitle,  emptyStateSubtitle,  hideOverflow = true,  carouselColorScheme = 'light',}: Pick<  CardCarouselProps,  | 'className'  | 'emptyStateTitle'  | 'emptyStateSubtitle'  | 'hideOverflow'  | 'placeholderCount'  | 'carouselColorScheme'>) {  return (    <Skeleton.Root className={clsx('relative', className)} hideOverflow={hideOverflow}>      <div className="w-full">        <div className="-ml-4 flex [mask-image:linear-gradient(to_bottom,_black_0%,_transparent_90%)] @2xl:-ml-5">          {Array.from({ length: placeholderCount }).map((_, index) => (            <div              className="min-w-0 shrink-0 grow-0 basis-full @sm:basis-1/2 @md:basis-1/3 @2xl:pl-5 @4xl:basis-1/4"              key={index}            >              <CardSkeleton />            </div>          ))}        </div>      </div>      <div className="absolute inset-0 mx-auto px-3 py-16 pb-3 @4xl:px-10 @4xl:pb-10 @4xl:pt-28">        <div className="mx-auto max-w-xl space-y-2 text-center @4xl:space-y-3">          <h3            className={clsx(              '@4x:leading-none font-heading text-2xl leading-tight @4xl:text-4xl',              {                light: 'text-[var(--card-carousel-light-empty-title,hsl(var(--foreground)))]',                dark: 'text-[var(--card-carousel-dark-empty-title,hsl(var(--background)))]',              }[carouselColorScheme],            )}          >            {emptyStateTitle}          </h3>          <p            className={clsx(              'text-sm @4xl:text-lg',              {                light: 'text-[var(--card-carousel-light-empty-subtitle,hsl(var(--contrast-500)))]',                dark: 'text-[var(--card-carousel-dark-empty-subtitle,hsl(var(--contrast-200)))]',              }[carouselColorScheme],            )}          >            {emptyStateSubtitle}          </p>        </div>      </div>    </Skeleton.Root>  );}

Usage

import { type CardContent } from '@/vibes/soul/primitives/card';import { CardCarousel } from '@/vibes/soul/sections/card-carousel';function Usage() {  return (      <CardCarousel          cards={cards}          className="w-full"          emptyStateSubtitle="Try browsing our complete catalog of products."          emptyStateTitle="No products found"          iconColorScheme="dark"      />  );}const cards: CardContent[] = [  {    title: 'Mini Bar Bag',    image: {      src: 'https://rstr.in/monogram/vibes/mrlTNE1TJfB',      alt: 'Mini Bar Bag',    },    href: '#1',  },  {    title: 'Mini Bar Bag',    image: {      src: 'https://rstr.in/monogram/vibes/LznMEk1GSB1',      alt: 'Mini Bar Bag',    },    href: '#2',  },  {    title: 'Stem Caddy',    image: {      src: 'https://rstr.in/monogram/vibes/EpL5yspw4Pc',      alt: 'Stem Caddy',    },    href: '#3',  },  {    title: 'Hip Slinger',    image: {      src: 'https://rstr.in/monogram/vibes/z6b0vDjJv6x',      alt: 'Hip Slinger',    },    href: '#4',  },  {    title: 'Everyday Tote',    image: {      src: 'https://rstr.in/monogram/vibes/1tVm6tBbJq9',      alt: 'Everyday Tote',    },    href: '#5',  },  {    title: 'Mini Saddlebag',    image: {      src: 'https://rstr.in/monogram/vibes/MZX8-yya26e',      alt: 'Mini Saddlebag',    },    href: '#6',  },];

API Reference

CardCarouselProps

PropTypeDefault
cards*
Streamable<CardContent[]>
aspectRatio
'5:6' | '3:4' | '1:1'
textColorScheme
'light'| 'dark'
iconColorScheme
'light'| 'dark'
carouselColorScheme
'light'| 'dark'
'light'
className
string
emptyStateTitle
Streamable<string>
emptyStateSubtitle
Streamable<string>
scrollbarLabel
string
previousLabel
string
nextLabel
string
showButtons
boolean
showScrollbar
boolean
hideOverflow
boolean
placeholderCount
number

CardContent

PropTypeDefault
title*
string
image
{ src: string; alt: string }
href*
string

CSS Variables

This component supports various CSS variables for theming. Here's a comprehensive list.

:root {  --card-carousel-light-empty-title: hsl(var(--foreground));  --card-carousel-light-empty-subtitle: hsl(var(--contrast-500));  --card-carousel-dark-empty-title: hsl(var(--background));  --card-carousel-dark-empty-subtitle: hsl(var(--contrast-100));}