Spaces:
Sleeping
Sleeping
| 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> | |
| ) | |
| } | |