Travel DeckA swipeable deck of destination cards.
Maafushi
MaldivesSwiss Alps
SwitzerlandBali
Indonesiaswipe down to shuffle
'use client'
// npm install @phosphor-icons/react framer-motion
import { useRef, useState, useLayoutEffect, useEffect } from 'react'
import { motion } from 'framer-motion'
import { Buildings, ArrowUpRight } from '@phosphor-icons/react'
const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect
const CARD_W = 280
const CARD_H = 200
const CARDS = [
{ title: 'Maafushi', sub: 'Crystal waters', img: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?w=520&q=80&fit=crop&crop=center', country: 'Maldives', hotels: 120 },
{ title: 'Swiss Alps', sub: 'Powder & peaks', img: 'https://images.unsplash.com/photo-1491555103944-7c647fd857e6?w=520&q=80&fit=crop&crop=center', country: 'Switzerland', hotels: 87 },
{ title: 'Bali', sub: 'Sun-soaked shores', img: 'https://images.unsplash.com/photo-1510414842594-a61c69b5ae57?w=520&q=80&fit=crop&crop=center', country: 'Indonesia', hotels: 250 },
] as const
// Deck slots: index 0 = back, index 1 = middle, index 2 = front
const POSITIONS = [
{ x: 12, y: -32, rotate: 6, zIndex: 1, opacity: 1.00 }, // back — peeks top-right
{ x: 6, y: -18, rotate: 4, zIndex: 2, opacity: 1.00 }, // middle — peeks top-right
{ x: 0, y: 0, rotate: 0, zIndex: 3, opacity: 1.00 }, // front — no rotation, full opacity
] as const
function useCountUp(target: number, active: boolean) {
const [value, setValue] = useState(0)
useIsomorphicLayoutEffect(() => {
if (!active) { setValue(0); return }
const duration = 900
const steps = 40
const interval = duration / steps
let step = 0
const id = setInterval(() => {
step++
setValue(Math.round((step / steps) * target))
if (step >= steps) clearInterval(id)
}, interval)
return () => clearInterval(id)
}, [active, target])
return value
}
function HotelsCounter({ target, active }: { target: number; active: boolean }) {
const value = useCountUp(target, active)
return (
<div style={{
display: 'flex',
alignItems: 'center',
gap: 5,
}}>
<Buildings weight="regular" size={16} style={{ color: '#E8E8DF', opacity: 0.55 }} />
<span style={{
fontSize: 10,
fontWeight: 500,
color: 'rgba(255,255,255,0.55)',
letterSpacing: '0.04em',
fontVariantNumeric: 'tabular-nums',
}}>
hotels <span style={{ color: 'rgba(255,255,255,0.85)', marginLeft: 2 }}>{value}</span>
</span>
</div>
)
}
export default function FloatingCards() {
const containerRef = useRef<HTMLDivElement>(null)
const [isDark, setIsDark] = useState(() => typeof window !== 'undefined' ? document.documentElement.classList.contains('dark') : false)
// order[positionIndex] = cardIndex
const [order, setOrder] = useState<[number, number, number]>([0, 1, 2])
const [isShuffling, setIsShuffling] = useState(false)
const [exitingCard, setExitingCard] = useState<number | null>(null)
useIsomorphicLayoutEffect(() => {
const el = containerRef.current
if (!el) return
const check = () => {
const card = el.closest('[data-card-theme]')
setIsDark(card ? card.classList.contains('dark') : document.documentElement.classList.contains('dark'))
}
check()
const observer = new MutationObserver(check)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
const cardWrapper = el.closest('[data-card-theme]')
if (cardWrapper) observer.observe(cardWrapper, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
return (
<div
ref={containerRef}
className="relative flex min-h-screen w-full items-center justify-center overflow-hidden"
style={{ background: isDark ? '#110F0C' : '#F5F1EA' }}
>
{/* Dot grid */}
<div
className="absolute inset-0"
style={{
backgroundImage: `radial-gradient(circle, ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.07)'} 1px, transparent 1px)`,
backgroundSize: '24px 24px',
}}
/>
{/* Deck */}
<div style={{ position: 'relative', width: CARD_W, height: CARD_H }}>
{CARDS.map((card, i) => {
const posIndex = order.indexOf(i)
const pos = POSITIONS[posIndex]
const isFront = posIndex === 2
const isExiting = exitingCard === i
return (
<motion.div
key={i}
style={{
position: 'absolute',
top: 0,
left: 0,
width: CARD_W,
height: CARD_H,
cursor: isFront ? 'grab' : 'default',
borderRadius: 16,
background: '#2A2825',
border: '1px solid rgba(255,255,255,0.10)',
boxShadow: isDark
? '8px -8px 24px rgba(0,0,0,0.35)'
: '8px -8px 24px rgba(0,0,0,0.15)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
overflow: 'hidden',
padding: 0,
}}
drag={isFront && !isShuffling ? 'y' : false}
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.6 }}
dragMomentum={false}
onDragEnd={(_e, info) => {
if (!isFront || isShuffling) return
if (info.offset.y > 80 || info.velocity.y > 400) {
setIsShuffling(true)
setExitingCard(i)
}
}}
animate={isExiting
? { y: 600, rotate: 8, opacity: 0 }
: { x: pos.x, y: pos.y, rotate: pos.rotate, zIndex: pos.zIndex, opacity: pos.opacity }
}
transition={isExiting
? { duration: 0.45, ease: 'easeIn' }
: { type: 'spring', stiffness: 80, damping: 16 }
}
onAnimationComplete={() => {
if (isExiting) {
setExitingCard(null)
setOrder(prev => [prev[2], prev[0], prev[1]])
setTimeout(() => setIsShuffling(false), 400)
}
}}
>
{/* ── Top section ───────────────────────────── */}
<div style={{
padding: '10px 10px 0',
display: 'flex',
flexDirection: 'column',
gap: 8,
flex: '0 0 auto',
}}>
{/* Header row: hotels counter left, arrow button right */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{/* Left cluster: hotels counter */}
<HotelsCounter target={card.hotels} active={isFront && !isExiting} />
{/* Arrow button */}
<div style={{
width: 26, height: 26, borderRadius: '50%',
background: '#BECF5D',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<ArrowUpRight weight="bold" size={13} style={{ color: '#1A1A19' }} />
</div>
</div>
{/* Title + distance */}
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between' }}>
<p style={{
fontSize: 17,
fontWeight: 800,
lineHeight: 1.15,
color: '#FFFFFF',
letterSpacing: '-0.02em',
margin: 0,
}}>
{card.title}
</p>
<span style={{
fontSize: 10,
fontWeight: 500,
color: 'rgba(255,255,255,0.45)',
letterSpacing: '0.04em',
}}>
{card.country}
</span>
</div>
</div>
{/* ── Visual block ──────────────────────────── */}
<div style={{
margin: '8px 6px 6px',
borderRadius: 12,
flex: 1,
position: 'relative',
overflow: 'hidden',
backgroundImage: `url(${card.img})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}}>
</div>
</motion.div>
)
})}
</div>
{/* Hint */}
<p
style={{
position: 'absolute',
bottom: 24,
fontSize: 11,
color: isDark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.25)',
letterSpacing: '0.05em',
}}
>
swipe down to shuffle
</p>
</div>
)
}
Add to your project
One command adds this component to your project.
Run the following command. New project? Run npx shadcn@latest init first to set up Tailwind and path aliases.
npx shadcn@latest add @aicanvas/traveldeckFor dark mode, add the dark class to your <html> element:
<html class="dark">Install with AI Canvas MCP
With AI Canvas MCP, your AI knows every component we ship. Ask for “a navigation component from AI Canvas” inside Claude Code, Codex, or Cursor and it can suggest you a few options, then install the one you like. Less typing, lower token cost, modern way to build.
Get MCPAbout Travel Deck
Travel Deck is a swipeable stack of destination cards that you flick through with a downward drag on the front card. As the top card recedes it animates a hotel-count badge that counts up to its destination total, then settles into the back of the stack. Motion handles both the drag tracking and the spring-back of dismissed cards into the back of the deck. It is a strong choice for image galleries, travel marketing pages, portfolio sliders, and product showcases that benefit from a tactile, deck-of-cards interaction.


