Carousel Cards

A responsive horizontal carousel of experience cards with snap scrolling, custom navigation, ratings, and badges.

A horizontally scrolling carousel of experience cards with ratings, review counts, badges, and custom navigation buttons. Snap scrolling provides a smooth, tactile feel ideal for showcasing products, experiences, or destinations.

Installation

npm install lucide-react react
pnpm install lucide-react react
yarn add lucide-react react
bun add lucide-react react
"use client";/** * @author: @dorianbaffier * @description: Carousel Cards * @version: 1.0.0 * @date: 2025-06-26 * @license: MIT * @website: https://kokonutui.com * @github: https://github.com/kokonut-labs/kokonutui */import { ChevronLeft, ChevronRight, Heart, Star } from "lucide-react";import Image from "next/image";import Link from "next/link";import React from "react";import { Badge } from "@/components/ui/badge";import { Button } from "@/components/ui/button";import { Card, CardContent, CardFooter } from "@/components/ui/card";interface ExperienceItem {  id: string;  title: string;  image: string;  location: string;  price: number;  currency?: string;  rating?: number;  reviewCount?: number;  badge?: string;  date?: string;}interface ExperienceGridProps {  title: string;  items: ExperienceItem[];  viewAllHref?: string;}const sampleExperiences: ExperienceItem[] = [  {    id: "1",    title: "Become an Otaku Hottie with Megan Thee Stallion",    image: "https://images.unsplash.com/photo-1615571022219-eb45cf7faa9d",    location: "Los Angeles, United States",    price: 120,    currency: "€",    rating: 4.97,    reviewCount: 128,    badge: "Original",    date: "Closes May 21",  },  {    id: "2",    title: "Spend a Sunday Funday with Patrick Mahomes",    image: "https://images.unsplash.com/photo-1622127922040-13cab637ee78",    location: "Kansas City, United States",    price: 150,    currency: "€",    rating: 4.92,    reviewCount: 86,    badge: "Original",    date: "Closes Today",  },  {    id: "3",    title: "Celebrate with SEVENTEEN on their 10th anniversary",    image: "https://images.unsplash.com/photo-1534430480872-3498386e7856",    location: "Seoul, South Korea",    price: 200,    currency: "€",    rating: 4.98,    reviewCount: 254,    badge: "Original",    date: "Closed May 17",  },  {    id: "4",    title: "Learn the secrets of French pastry with nonnas",    image: "https://images.unsplash.com/photo-1604999333679-b86d54738315",    location: "Paris, France",    price: 70,    currency: "€",    rating: 4.97,    reviewCount: 112,    badge: "Original",  },  {    id: "5",    title: "Uncover the world of cabaret with a burlesque show",    image: "https://images.unsplash.com/photo-1552733407-5d5c46c3bb3b",    location: "Paris, France",    price: 92,    currency: "€",    rating: 4.9,    reviewCount: 78,    badge: "Original",  },  {    id: "6",    title: "The Super Powers of Art-family Game at the Louvre",    image: "https://images.unsplash.com/photo-1530789253388-582c481c54b0",    location: "Paris, France",    price: 90,    currency: "€",    rating: 4.98,    reviewCount: 146,    badge: "Original",  },  {    id: "7",    title: "Savor tasty vegan pastries with a plant-based pro",    image: "https://images.unsplash.com/photo-1608830597604-619220679440",    location: "Paris, France",    price: 75,    currency: "€",    rating: 4.95,    reviewCount: 92,    badge: "Original",  },];const popularExperiences: ExperienceItem[] = [  {    id: "p1",    title: "Learn to bake the French Croissant",    image: "https://images.unsplash.com/photo-1555507036-ab1f4038808a",    location: "Paris, France",    price: 95,    currency: "€",    rating: 4.95,    reviewCount: 218,    badge: "Popular",  },  {    id: "p2",    title: "Seek out hidden speakeasy bars in the city",    image: "https://images.unsplash.com/photo-1470337458703-46ad1756a187",    location: "Paris, France",    price: 74,    currency: "€",    rating: 4.9,    reviewCount: 165,    badge: "Popular",  },  {    id: "p3",    title: "Versailles Food and Palace Bike Tour",    image: "https://images.unsplash.com/photo-1555507036-ab1f4038808a",    location: "Versailles, France",    price: 122,    currency: "€",    rating: 4.97,    reviewCount: 89,    badge: "Popular",  },  {    id: "p4",    title: "Haunted Paris Tour - Ghosts, Legends, True Crime",    image: "https://images.unsplash.com/photo-1549144511-f099e773c147",    location: "Paris, France",    price: 25,    currency: "€",    rating: 4.98,    reviewCount: 345,    badge: "Popular",  },  {    id: "p5",    title: "Learn to make the French macarons with a chef",    image: "https://images.unsplash.com/photo-1558326567-98ae2405596b",    location: "Paris, France",    price: 110,    currency: "€",    rating: 4.95,    reviewCount: 203,    badge: "Popular",  },  {    id: "p6",    title: "No Diet Club - Best food tour in Le Marais",    image: "https://images.unsplash.com/photo-1414235077428-338989a2e8c0",    location: "Paris, France",    price: 65,    currency: "€",    rating: 4.92,    reviewCount: 178,    badge: "5 spots left",  },  {    id: "p7",    title: "Soak up the nightlife of Paris",    image: "https://images.unsplash.com/photo-1546636889-ba9fdd63583e",    location: "Paris, France",    price: 20,    currency: "€",    rating: 4.94,    reviewCount: 112,    badge: "Popular",  },];const ExperienceCard = ({ experience }: { experience: ExperienceItem }) => (  <Card className="group relative flex h-full w-full flex-col overflow-hidden rounded-xl border-border bg-card shadow-theme-sm transition-shadow duration-300 hover:shadow-theme-md">    <div className="relative aspect-[4/3] w-full overflow-hidden rounded-t-xl">      <Image        alt={experience.title}        className="object-cover transition-transform duration-300 group-hover:scale-105"        fill        sizes="(max-width: 640px) 85vw, (max-width: 1024px) 50vw, 33vw"        src={experience.image}      />      <Button        className="absolute top-2 right-2 z-10 rounded-full bg-background/80 text-foreground backdrop-blur-sm hover:bg-background/90"        size="icon"        variant="ghost"      >        <Heart className="h-4 w-4 stroke-[2px]" />        <span className="sr-only">Add to favorites</span>      </Button>      {experience.badge && (        <Badge className="absolute top-2 left-2 rounded-md bg-background/90 px-1.5 py-0.5 font-medium text-foreground text-xs backdrop-blur-sm">          {experience.badge}        </Badge>      )}    </div>    <div className="flex flex-1 flex-col justify-between">      <CardContent className="p-2 pt-3 pb-0">        <h3 className="font-medium text-sm tracking-tight text-foreground">          {experience.title}        </h3>        <p className="text-muted-foreground text-xs tracking-tight">          {experience.location}        </p>        {experience.date && (          <p className="text-muted-foreground text-xs tracking-tight">            {experience.date}          </p>        )}      </CardContent>      <CardFooter className="mt-auto flex items-center gap-0.5 p-2 pt-0 text-xs">        {experience.rating && (          <span className="flex items-center gap-0.5">            <Star className="h-3 w-3 fill-current" />            {experience.rating}          </span>        )}        {experience.reviewCount && (          <span className="text-muted-foreground text-xs tracking-tight">            {experience.rating && "·"}            {experience.reviewCount > 0 ? ` (${experience.reviewCount})` : ""}          </span>        )}        <span className="ml-auto text-xs tracking-tight">          {experience.currency || "€"} {experience.price} / guest        </span>      </CardFooter>    </div>  </Card>);const ExperienceSection = ({  title,  items,  viewAllHref = "#",}: ExperienceGridProps) => {  const scrollContainer = React.useRef<HTMLDivElement>(null);  const getScrollDistance = () => {    const viewport = scrollContainer.current ?? null;    if (viewport) {      return Math.max(Math.min(viewport.clientWidth * 0.8, 640), 260);    }    return 300;  };  const handleScrollLeft = () => {    const viewport = scrollContainer.current ?? null;    if (viewport) {      viewport.scrollBy({        left: -getScrollDistance(),        behavior: "smooth",      });    }  };  const handleScrollRight = () => {    const viewport = scrollContainer.current ?? null;    if (viewport) {      viewport.scrollBy({        left: getScrollDistance(),        behavior: "smooth",      });    }  };  return (    <div className="w-full py-4">      <div className="mx-auto max-w-[1760px] px-4 sm:px-6 lg:px-8">        <div className="mb-3 flex items-center justify-between gap-3">          <h2 className="font-medium text-base tracking-tight sm:text-lg md:text-xl">            {title}          </h2>          <div className="flex items-center gap-1">            <Button              className="h-7 w-7 rounded-full border-border bg-card text-foreground hover:bg-secondary md:h-8 md:w-8"              onClick={handleScrollLeft}              size="icon"              variant="outline"              aria-label="Scroll left"            >              <ChevronLeft className="h-4 w-4" />            </Button>            <Button              className="h-7 w-7 rounded-full border-border bg-card text-foreground hover:bg-secondary md:h-8 md:w-8"              onClick={handleScrollRight}              size="icon"              variant="outline"              aria-label="Scroll right"            >              <ChevronRight className="h-4 w-4" />            </Button>            <Link              className="ml-1 hidden font-medium text-xs text-foreground hover:underline md:block"              href={viewAllHref}            >              Show all            </Link>          </div>        </div>        <div          className="scrollbar-hide -mx-1 flex snap-x snap-mandatory gap-3 overflow-x-auto px-1 pb-2"          ref={scrollContainer}          style={{            scrollbarWidth: "none",            msOverflowStyle: "none",          }}        >          {items.map((item) => (            <div              className="w-[220px] flex-none snap-start sm:w-[240px] md:w-[260px] lg:w-[280px]"              key={item.id}            >              <Link                className="block rounded-xl h-[300px] sm:h-[320px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"                href="#"              >                <ExperienceCard experience={item} />              </Link>            </div>          ))}        </div>      </div>    </div>  );};export default function CarouselCards() {  return (    <div className="mt-4 w-full space-y-4">      <ExperienceSection        items={sampleExperiences}        title="Airbnb Originals ›"        viewAllHref="#"      />      <ExperienceSection        items={popularExperiences}        title="Popular experiences in Paris"        viewAllHref="#"      />    </div>  );}

Usage

import CarouselCards from "@/components/mvpblocks/cards/carousel-card";

export default function MyComponent() {
  return (
    <div className="min-h-screen p-8">
      <CarouselCards />
    </div>
  );
}

Props

PropTypeDescription
titlestringSection heading shown above the carousel
itemsExperienceItem[]Array of experience cards to display
viewAllHrefstringOptional link target for the "Show all" label

ExperienceItem

PropTypeDescription
idstringUnique key for the card
titlestringCard title
imagestringImage URL for the card thumbnail
locationstringDisplay location text
pricenumberPrice displayed in the footer
currencystringOptional currency symbol (defaults to €)
ratingnumberOptional star rating
reviewCountnumberOptional review count
badgestringOptional badge text overlaid on the image
datestringOptional date line under the location

Features

  • Responsive: Card widths scale from 220px on mobile up to 280px on large screens, with scroll distance adapting to the viewport
  • Snap Scrolling: Snap-mandatory horizontal scroll for a smooth, app-like feel with hidden scrollbars
  • Custom Navigation: Left/right arrow buttons scroll by a viewport-aware distance
  • Rich Card Content: Ratings, review counts, badges, location, and price per card
  • Themed: Uses the current design tokens (card surfaces, borders, foreground/muted text, primary focus rings)

Last updated on