gridloc / src /components /ui /morphing-card-stack.tsx
lassanmonster's picture
GridLoc: Bengaluru parking congestion digital twin
db3dbe1
Raw
History Blame Contribute Delete
8.73 kB
import { useState, type ReactNode } from 'react'
import { motion, AnimatePresence, LayoutGroup, type PanInfo } from 'framer-motion'
import { cn } from '../../lib/utils'
import { Grid3X3, Layers, LayoutList } from 'lucide-react'
export type LayoutMode = 'stack' | 'grid' | 'list'
export interface CardData {
id: string
title: string
description: string
icon?: ReactNode
color?: string
}
export interface MorphingCardStackProps {
cards?: CardData[]
className?: string
defaultLayout?: LayoutMode
onCardClick?: (card: CardData) => void
}
const layoutIcons = {
stack: Layers,
grid: Grid3X3,
list: LayoutList,
}
const SWIPE_THRESHOLD = 50
export function Component({
cards = [],
className,
defaultLayout = 'stack',
onCardClick,
}: MorphingCardStackProps) {
const [layout, setLayout] = useState<LayoutMode>(defaultLayout)
const [expandedCard, setExpandedCard] = useState<string | null>(null)
const [activeIndex, setActiveIndex] = useState(0)
const [isDragging, setIsDragging] = useState(false)
if (!cards || cards.length === 0) {
return null
}
const handleDragEnd = (
_event: MouseEvent | TouchEvent | PointerEvent,
info: PanInfo,
) => {
const { offset, velocity } = info
const swipe = Math.abs(offset.x) * velocity.x
if (offset.x < -SWIPE_THRESHOLD || swipe < -1000) {
// Swiped left - go to next card
setActiveIndex((prev) => (prev + 1) % cards.length)
} else if (offset.x > SWIPE_THRESHOLD || swipe > 1000) {
// Swiped right - go to previous card
setActiveIndex((prev) => (prev - 1 + cards.length) % cards.length)
}
setIsDragging(false)
}
const getStackOrder = () => {
const reordered = []
for (let i = 0; i < cards.length; i++) {
const index = (activeIndex + i) % cards.length
reordered.push({ ...cards[index], stackPosition: i })
}
return reordered.reverse() // Reverse so top card renders last (on top)
}
const getLayoutStyles = (stackPosition: number) => {
switch (layout) {
case 'stack':
return {
top: stackPosition * 8,
left: stackPosition * 8,
zIndex: cards.length - stackPosition,
rotate: (stackPosition - 1) * 2,
}
case 'grid':
return {
top: 0,
left: 0,
zIndex: 1,
rotate: 0,
}
case 'list':
return {
top: 0,
left: 0,
zIndex: 1,
rotate: 0,
}
}
}
const containerStyles = {
stack: 'relative h-[380px] w-[320px] sm:w-[380px]',
grid: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full max-w-[1100px]',
list: 'flex flex-col gap-4 w-full max-w-3xl',
}
const displayCards =
layout === 'stack'
? getStackOrder()
: cards.map((c, i) => ({ ...c, stackPosition: i }))
return (
<div className={cn('space-y-8', className)}>
{/* Layout Toggle */}
<div
className="flex items-center justify-center gap-1 rounded-full p-1.5 w-fit mx-auto backdrop-blur-sm"
style={{ border: '1px solid var(--lp-line)', background: 'var(--lp-chip)' }}
>
{(Object.keys(layoutIcons) as LayoutMode[]).map((mode) => {
const Icon = layoutIcons[mode]
return (
<button
key={mode}
onClick={() => setLayout(mode)}
className={cn(
'rounded-full p-2.5 transition-all cursor-pointer',
layout === mode
? 'bg-[var(--lp-text)] text-[var(--lp-bg)] shadow-sm'
: 'text-[var(--lp-muted)] hover:text-[var(--lp-text)] hover:bg-[var(--lp-line)]/50',
)}
aria-label={`Switch to ${mode} layout`}
>
<Icon className="h-[18px] w-[18px]" />
</button>
)
})}
</div>
{/* Cards Container */}
<LayoutGroup>
<motion.div layout className={cn(containerStyles[layout], 'mx-auto')}>
<AnimatePresence mode="popLayout">
{displayCards.map((card) => {
const styles = getLayoutStyles(card.stackPosition)
const isExpanded = expandedCard === card.id
const isTopCard = layout === 'stack' && card.stackPosition === 0
return (
<motion.div
key={card.id}
layoutId={card.id}
initial={{ opacity: 0, scale: 0.8 }}
animate={{
opacity: 1,
scale: isExpanded ? 1.02 : 1,
x: 0,
...styles,
}}
exit={{ opacity: 0, scale: 0.8, x: -200 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
}}
drag={isTopCard ? 'x' : false}
dragConstraints={{ left: 0, right: 0 }}
dragElastic={0.7}
onDragStart={() => setIsDragging(true)}
onDragEnd={handleDragEnd}
whileDrag={{ scale: 1.02, cursor: 'grabbing' }}
onClick={() => {
if (isDragging) return
setExpandedCard(isExpanded ? null : card.id)
onCardClick?.(card)
}}
className={cn(
'cursor-pointer rounded-2xl overflow-hidden transition-colors',
layout === 'stack' && 'absolute w-full h-full p-8',
layout === 'stack' &&
isTopCard &&
'cursor-grab active:cursor-grabbing',
layout === 'grid' && 'p-8',
layout === 'list' && 'p-6 md:p-8',
)}
style={{
backgroundColor: 'var(--lp-bg)',
border: isExpanded ? '1px solid var(--lp-text)' : '1px solid var(--lp-line)',
boxShadow: layout === 'stack' ? '0 10px 40px -10px rgba(0,0,0,0.2)' : 'none',
}}
>
<div className={cn(
"flex",
layout === 'list' ? "flex-col sm:flex-row sm:items-center gap-6" : "flex-col items-start gap-5"
)}>
{card.icon && (
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-secondary text-foreground">
{card.icon}
</div>
)}
<div className="min-w-0 flex-1">
<h3
className="font-semibold"
style={{
color: 'var(--lp-text)',
fontFamily: 'var(--font-display)',
fontSize: layout === 'grid' ? '1.25rem' : '1.25rem',
lineHeight: 1.2
}}
>
{card.title}
</h3>
<p
className="mt-2.5 leading-relaxed"
style={{
color: 'var(--lp-muted)',
fontSize: layout === 'stack' ? '0.95rem' : '1rem'
}}
>
{card.description}
</p>
</div>
</div>
{isTopCard && (
<div className="absolute bottom-6 left-0 right-0 text-center">
<span className="text-[11px] uppercase tracking-widest" style={{ color: 'var(--lp-eyebrow)', fontFamily: 'var(--font-mono)' }}>
Swipe to navigate
</span>
</div>
)}
</motion.div>
)
})}
</AnimatePresence>
</motion.div>
</LayoutGroup>
{layout === 'stack' && cards.length > 1 && (
<div className="flex justify-center gap-1.5 mt-16 relative z-20">
{cards.map((_, index) => (
<button
key={index}
onClick={() => setActiveIndex(index)}
className={cn(
'h-1.5 rounded-full transition-all cursor-pointer',
index === activeIndex
? 'w-4 bg-primary'
: 'w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50',
)}
aria-label={`Go to card ${index + 1}`}
/>
))}
</div>
)}
</div>
)
}