Product Carousel

Installation

Add the following Soul components

The product-carousel component uses the product-card, carousel, skeleton 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/product-carousel/index.tsx

import { clsx } from 'clsx';import { ArrowLeft, ArrowRight } from 'lucide-react';import { Stream, Streamable } from '@/vibes/soul/lib/streamable';import {  Carousel,  CarouselButtons,  CarouselContent,  CarouselItem,  CarouselScrollbar,} from '@/vibes/soul/primitives/carousel';import {  type Product,  ProductCard,  ProductCardSkeleton,} from '@/vibes/soul/primitives/product-card';import * as Skeleton from '@/vibes/soul/primitives/skeleton';export type CarouselProduct = Product;export interface ProductCarouselProps {  products: Streamable<CarouselProduct[]>;  className?: string;  colorScheme?: 'light' | 'dark';  aspectRatio?: '5:6' | '3:4' | '1:1';  emptyStateTitle?: Streamable<string>;  emptyStateSubtitle?: Streamable<string>;  scrollbarLabel?: string;  previousLabel?: string;  nextLabel?: string;  placeholderCount?: number;  showButtons?: boolean;  showScrollbar?: boolean;  hideOverflow?: boolean;}/** * This component supports various CSS variables for theming. Here's a comprehensive list, along * with their default values: * * ```css * :root { *   --product-carousel-light-empty-title: hsl(var(--foreground)); *   --product-carousel-light-empty-subtitle: hsl(var(--contrast-500)); *   --product-carousel-dark-empty-title: hsl(var(--background)); *   --product-carousel-dark-empty-subtitle: hsl(var(--contrast-100)); *   --product-carousel-empty-title-font-family: var(--font-family-heading); *   --product-carousel-empty-subtitle-font-family: var(--font-family-body); * } * ``` */export function ProductCarousel({  products: streamableProducts,  className,  colorScheme = 'light',  aspectRatio = '5:6',  emptyStateTitle = 'No products found',  emptyStateSubtitle = 'Try browsing our complete catalog of products.',  scrollbarLabel = 'Scroll',  previousLabel = 'Previous',  nextLabel = 'Next',  placeholderCount = 8,  showButtons = true,  showScrollbar = true,  hideOverflow = true,}: ProductCarouselProps) {  return (    <Stream      fallback={        <ProductsCarouselSkeleton          className={className}          hideOverflow={hideOverflow}          placeholderCount={placeholderCount}        />      }      value={streamableProducts}    >      {(products) => {        if (products.length === 0) {          return (            <ProductsCarouselEmptyState              className={className}              colorScheme={colorScheme}              emptyStateSubtitle={emptyStateSubtitle}              emptyStateTitle={emptyStateTitle}              hideOverflow={hideOverflow}              placeholderCount={placeholderCount}            />          );        }        return (          <Carousel className={className} hideOverflow={hideOverflow}>            <CarouselContent className="-ml-4 mb-10 @2xl:-ml-5">              {products.map(({ id, ...product }) => (                <CarouselItem                  className="basis-full pl-4 @md:basis-1/2 @lg:basis-1/3 @2xl:basis-1/4 @2xl:pl-5"                  key={id}                >                  <ProductCard                    aspectRatio={aspectRatio}                    colorScheme={colorScheme}                    imageSizes="(min-width: 42rem) 25vw, (min-width: 32rem) 33vw, (min-width: 28rem) 50vw, 100vw"                    product={{ id, ...product }}                  />                </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={colorScheme}                  label={scrollbarLabel}                />                <CarouselButtons                  className={clsx(!showButtons && 'pointer-events-none invisible')}                  colorScheme={colorScheme}                  nextLabel={nextLabel}                  previousLabel={previousLabel}                />              </div>            )}          </Carousel>        );      }}    </Stream>  );}export function ProductsCarouselSkeleton({  className,  placeholderCount = 8,  hideOverflow,}: Pick<ProductCarouselProps, 'className' | 'placeholderCount' | 'hideOverflow'>) {  return (    <Skeleton.Root      className={clsx('group-has-[[data-pending]]/product-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 pl-4 @md:basis-1/2 @lg:basis-1/3 @2xl:basis-1/4 @2xl:pl-5"              key={index}            >              <ProductCardSkeleton />            </div>          ))}        </div>      </div>      <div className="mt-10 flex w-full items-center justify-between gap-8">        <Skeleton.Box className="h-1 w-56 rounded" />        <div className="flex gap-2">          <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 ProductsCarouselEmptyState({  className,  placeholderCount = 8,  emptyStateTitle,  emptyStateSubtitle,  hideOverflow,  colorScheme = 'light',}: Pick<  ProductCarouselProps,  | 'className'  | 'placeholderCount'  | 'emptyStateTitle'  | 'emptyStateSubtitle'  | 'hideOverflow'  | 'colorScheme'>) {  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 pl-4 @md:basis-1/2 @lg:basis-1/3 @2xl:basis-1/4 @2xl:pl-5"              key={index}            >              <ProductCardSkeleton />            </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(              'font-[family-name:var(--product-carousel-empty-title-font-family,var(--font-family-heading))] text-2xl leading-tight @4xl:text-4xl @4xl:leading-none',              {                light: 'text-[var(--product-carousel-light-empty-title,hsl(var(--foreground)))]',                dark: 'text-[var(--product-carousel-dark-empty-title,hsl(var(--background)))]',              }[colorScheme],            )}          >            {emptyStateTitle}          </h3>          <p            className={clsx(              'font-[family-name:var(--product-carousel-empty-subtitle-font-family,var(--font-family-body))] text-sm @4xl:text-lg',              {                light:                  'text-[var(--product-carousel-light-empty-subtitle,hsl(var(--contrast-500)))]',                dark: 'text-[var(--product-carousel-dark-empty-subtitle,hsl(var(--contrast-200)))]',              }[colorScheme],            )}          >            {emptyStateSubtitle}          </p>        </div>      </div>    </Skeleton.Root>  );}

Usage

import { ProductCarousel } from '@/vibes/soul/sections/product-carousel';function Usage() {  return (      <ProductCarousel products={products} />  );}const products = [  {    id: '1',    title: 'Product 1',  },]

API Reference

ProductCarouselProps

PropTypeDefault
className
string
products*
Streamable<CarouselProduct[]>
colorScheme
'light' | 'dark'
'light'
aspectRatio
'5:6' | '3:4' | '1:1'
'5:6'
emptyStateTitle
Streamable<string>
'No products found'
emptyStateSubtitle
Streamable<string>
'Try browsing our complete catalog of products.'
scrollbarLabel
string
'Scroll'
previousLabel
string
'Previous'
nextLabel
string
'Next'
placeholderCount
number
8
showButtons
boolean
true
showScrollbar
boolean
true
hideOverflow
boolean
true

CarouselProduct

PropTypeDefault
id*
string
title*
string
href*
string
image
{ src: string; alt: string }
price
string | PriceRange | PriceSale
subtitle
string
badge
string
rating
number

PriceRange

PropTypeDefault
type*
'range'
minValue*
string
maxValue*
string

PriceSale

PropTypeDefault
type*
'sale'
previousValue*
string
currentValue*
string

CSS Variables

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

:root {  --product-carousel-light-empty-title: hsl(var(--foreground));  --product-carousel-light-empty-subtitle: hsl(var(--contrast-500));  --product-carousel-dark-empty-title: hsl(var(--background));  --product-carousel-dark-empty-subtitle: hsl(var(--contrast-100));  --product-carousel-empty-title-font-family: var(--font-family-heading);  --product-carousel-empty-subtitle-font-family: var(--font-family-body);}