Velocity UI
Loading…
Menu

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add stack-carousel

Source Code

stack-carousel.tsx
'use client'

import React, { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence, useMotionValue, useTransform, useSpring } from 'framer-motion'
import { MapPin, Zap, ArrowUpRight, Plus, Info } from 'lucide-react'
import { cn } from '@/lib/utils'

const DATA = [
  {
    id: 1,
    title: 'Cricket Championship',
    subtitle: 'The Gentleman’s Game',
    location: 'Lord\'s, London',
    desc: 'A sport of strategy, patience, and skill, played with a bat and ball between two teams of eleven players. Known for its rich history and intense rivalries.',
    url: 'https://images.unsplash.com/photo-1565787113569-be8aa6f5da41?q=80&w=765&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
    stats: { Matches: 'Unlimited', Players: '11', Origin: 'England' },
    users: [
      'https://i.pravatar.cc/150?u=1',
      'https://i.pravatar.cc/150?u=2',
      'https://i.pravatar.cc/150?u=3',
    ],
  },
  {
    id: 2,
    title: 'Badminton Masters',
    subtitle: 'Speed and Agility',
    location: 'Indoor Court',
    desc: 'The fastest racquet sport in the world, requiring lightning-quick reflexes, explosive power, and precise shuttlecock control across the net.',
    url: 'https://images.unsplash.com/photo-1687597778602-624a9438fe0b?q=80&w=735&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
    stats: { Speed: '493km/h', Players: 'Singles/Doubles', Origin: 'India' },
    users: [
      'https://i.pravatar.cc/150?u=4',
      'https://i.pravatar.cc/150?u=5',
      'https://i.pravatar.cc/150?u=6',
    ],
  },
  {
    id: 3,
    title: 'Football League',
    subtitle: 'The Beautiful Game',
    location: 'Wembley, London',
    desc: 'The world\'s most popular sport, uniting billions through passion, skill, and the simple joy of kicking a ball into a goal.',
    url: 'https://images.unsplash.com/photo-1560272564-c83b66b1ad12?q=80&w=749&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
    stats: { Fans: '3.5B', Players: '11', Origin: 'Global' },
    users: [
      'https://i.pravatar.cc/150?u=7',
      'https://i.pravatar.cc/150?u=8',
      'https://i.pravatar.cc/150?u=9',
    ],
  },
]

const SHUFFLE_SFX =
  'data:audio/wav;base64,UklGRl9vT19XQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YTdvT18AZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICAZAGAgICA'

export const StackCarousel = ({ className, stackWidth }: { className?: string, stackWidth?: string }) => {
  const [cards, setCards] = useState(DATA)
  const audioRef = useRef<HTMLAudioElement | null>(null)
  const activeCard = cards[0]

  useEffect(() => {
    audioRef.current = new Audio(SHUFFLE_SFX)
  }, [])

  const handleNext = () => {
    if (audioRef.current) {
      audioRef.current.currentTime = 0
      audioRef.current.volume = 0.1
      audioRef.current.play().catch(() => {})
    }
    setCards((prev) => {
      const [first, ...rest] = prev
      return [...rest, first]
    })
  }

  return (
    <div className={cn("flex items-center justify-center min-h-[500px] h-full w-full p-6 bg-transparent text-zinc-900 dark:text-zinc-100 select-none overflow-hidden font-sans transition-colors duration-300", className)}>
      <div className="flex flex-col md:flex-row items-center md:items-start gap-10 w-full max-w-4xl justify-center">
        {/* LEFT STACK */}
        <div className={cn("relative w-full h-[400px]", stackWidth || "max-w-[300px]")}>
          <AnimatePresence mode="popLayout">
            {cards.map((card, index) => (
              <SwipeCard
                key={card.id}
                card={card}
                index={index}
                onSwipe={handleNext}
                total={cards.length}
              />
            ))}
          </AnimatePresence>
        </div>

        {/* RIGHT DETAIL CARD */}
        <div className="w-full max-w-[280px] pt-4">
          {' '}
          {/* Aligned to peak of stack */}
          <AnimatePresence mode="wait">
            <DetailCard key={activeCard.id} card={activeCard} />
          </AnimatePresence>
        </div>
      </div>
    </div>
  )
}

const DetailCard = ({ card }: { card: (typeof DATA)[0] }) => {
  const [isHovered, setIsHovered] = useState(false)

  return (
    <div className="border border-zinc-200 dark:border-white/10 rounded-[2.2rem] p-2 transition-colors duration-300">
      <div className="border border-dotted border-zinc-300 dark:border-white/20 rounded-[1.8rem] p-1 transition-colors duration-300">
        <motion.div
          initial={{ opacity: 0, x: 20 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -10 }}
          onMouseEnter={() => setIsHovered(true)}
          onMouseLeave={() => setIsHovered(false)}
          layout // Smooth internal layout shifts
          className="bg-zinc-100/50 dark:bg-zinc-900/40 backdrop-blur-3xl rounded-[1.5rem] p-6 shadow-2xl relative overflow-hidden flex flex-col transition-colors duration-300"
          style={{
            height: isHovered ? 'auto' : '155px', // Tight initial height, auto on hover
            transition: 'height 0.5s cubic-bezier(0.16, 1, 0.3, 1)',
          }}
        >
          <motion.div layout className="space-y-1 mb-4">
            <div className="flex items-center gap-2 text-zinc-500 dark:text-zinc-500">
              <Info size={12} />
              <span className="text-[9px] font-black uppercase tracking-widest">Description</span>
            </div>
            <h3 className="text-zinc-900 dark:text-white text-xl font-bold italic tracking-tight uppercase leading-none">
              {card.title}
            </h3>
          </motion.div>

          <div className="relative flex-1">
            <motion.p
              layout
              className={`text-zinc-600 dark:text-zinc-400 text-[11px] leading-relaxed transition-all duration-300 ${isHovered ? 'opacity-100' : 'line-clamp-2 opacity-60'}`}
            >
              {card.desc}
            </motion.p>

            <AnimatePresence>
              {isHovered && (
                <motion.div
                  initial={{ opacity: 0, y: 10 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0, y: 5 }}
                  className="mt-6 space-y-4"
                >
                  <div className="flex items-center gap-2 text-zinc-600 dark:text-white/50 text-[10px] font-bold uppercase tracking-widest">
                    <MapPin size={12} className="text-indigo-500" /> {card.location}
                  </div>
                  <button className="w-full py-3 bg-zinc-900 dark:bg-white text-white dark:text-black text-[10px] font-black uppercase rounded-xl hover:bg-zinc-800 dark:hover:bg-zinc-100 transition-colors flex items-center justify-center gap-2">
                    Explore <ArrowUpRight size={14} />
                  </button>
                </motion.div>
              )}
            </AnimatePresence>
          </div>

          {/* Profile Image Stack */}
          <motion.div
            layout
            className="mt-4 pt-4 flex items-center gap-3 border-t border-zinc-200 dark:border-white/5"
          >
            <div className="flex -space-x-2.5">
              {card.users.map((url, i) => (
                <img
                  key={i}
                  src={url}
                  className="w-7 h-7 rounded-full border-2 border-white dark:border-[#0d0d0d] object-cover"
                  alt=""
                />
              ))}
              <div className="w-7 h-7 rounded-full border-2 border-white dark:border-[#0d0d0d] bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-[8px] text-zinc-500 dark:text-white/40">
                <Plus size={10} />
              </div>
            </div>
            <span className="text-[8px] text-zinc-500 dark:text-zinc-600 font-bold uppercase tracking-widest">
              Exclusive Access
            </span>
          </motion.div>
        </motion.div>
      </div>
    </div>
  )
}

const SwipeCard = ({ card, index, onSwipe, total }: any) => {
  const x = useMotionValue(0)
  const springX = useSpring(x, { stiffness: 200, damping: 30 })
  const rotate = useTransform(springX, [-150, 150], [-25, 25])
  const isTop = index === 0

  return (
    <motion.div
      style={{ x: springX, rotate: isTop ? rotate : 0, zIndex: total - index }}
      drag={isTop ? 'x' : false}
      dragConstraints={{ left: 0, right: 0 }}
      onDragEnd={(_, info) => {
        if (Math.abs(info.offset.x) > 120 || Math.abs(info.velocity.x) > 600) onSwipe()
      }}
      animate={{
        y: index * -22,
        x: index * 10,
        rotate: index * -3,
        scale: 1 - index * 0.05,
        opacity: index > 3 ? 0 : 1 - index * 0.15,
      }}
      transition={{ type: 'spring', stiffness: 260, damping: 20 }}
      exit={{
        x: x.get() < 0 ? -600 : 600,
        opacity: 0,
        rotate: x.get() < 0 ? -45 : 45,
        transition: { duration: 0.4 },
      }}
      className={cn(
        'absolute inset-0 rounded-[2.5rem] cursor-grab active:cursor-grabbing overflow-hidden bg-white dark:bg-zinc-900 shadow-xl transition-colors duration-300',
        'border border-zinc-200 dark:border-white/10',
      )}
    >
      <div className="relative w-full h-full rounded-[2.4rem] overflow-hidden">
        <div
          className="absolute inset-0 bg-cover bg-center"
          style={{ backgroundImage: `url(${card.url})` }}
        />
        <div className="absolute inset-0 bg-gradient-to-t from-black/50 via-transparent to-transparent opacity-50" />
      </div>
    </motion.div>
  )
}

export default StackCarousel

Dependencies

  • framer-motion: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
itemsCarouselCardData[]Demo DataArray of card objects containing id, title, location, desc, url, and users.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.