Stack Carousel
A highly interactive, gesture-driven stack carousel that allows users to swipe through cards with physics-based animations. It features a synchronized detail view with a premium double-bordered glassmorphism design, adapting seamlessly to both light and dark modes.
Installation
Add this component to your project using the CLI:
npx vui-registry-cli-v1 add stack-carouselSource Code
'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: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| items | CarouselCardData[] | Demo Data | Array of card objects containing id, title, location, desc, url, and users. |
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.

