BannerStack.tsx80 lines · main
1import { AnimatePresence, motion } from 'framer-motion'
2import { useState } from 'react'
3import { cn } from 'ui'
4
5import { useBannerStack } from './BannerStackProvider'
6
7export const BannerStack = () => {
8 const { banners } = useBannerStack()
9 const [isHovered, setIsHovered] = useState(false)
10
11 const activeBanners = banners.filter((b) => !b.isDismissed)
12
13 const PEEK_HEIGHT = 4
14 const CARD_GAP = 4
15 const CARD_HEIGHT = 212
16
17 if (activeBanners.length === 0) return null
18
19 return (
20 <motion.div
21 className="fixed bottom-4 right-4 z-50"
22 onMouseEnter={() => setIsHovered(true)}
23 onMouseLeave={() => setIsHovered(false)}
24 animate={{
25 y: isHovered ? -8 : 0,
26 }}
27 transition={{
28 type: 'spring',
29 stiffness: 300,
30 damping: 25,
31 }}
32 >
33 <div className="relative">
34 <AnimatePresence mode="popLayout">
35 {activeBanners.map((banner, index) => {
36 const isBottomBanner = index === 0
37 const reverseIndex = activeBanners.length - 1 - index
38 const collapsedY = index * PEEK_HEIGHT
39 const expandedY = index * (CARD_HEIGHT + CARD_GAP)
40
41 return (
42 <motion.div
43 key={banner.id}
44 initial={{ opacity: 0, scale: 0.99, y: 8 }}
45 animate={{
46 opacity: 1,
47 scale: isHovered ? 1 : 1 - index * 0.07,
48 x: 0,
49 y: isHovered ? -expandedY : -collapsedY,
50 }}
51 exit={{ opacity: 0, scale: 0.99, y: 8 }}
52 transition={{
53 type: 'spring',
54 stiffness: 300,
55 damping: 30,
56 delay: 0.25,
57 }}
58 onMouseEnter={() => setIsHovered(true)}
59 onMouseLeave={() => setIsHovered(false)}
60 style={{
61 position: isBottomBanner ? 'relative' : 'absolute',
62 bottom: isBottomBanner ? undefined : 0,
63 right: isBottomBanner ? undefined : 0,
64 zIndex: 30 + reverseIndex,
65 transformOrigin: 'center bottom',
66 }}
67 className={cn(
68 'w-full max-w-72',
69 !isHovered && index === 0 && 'pointer-events-none'
70 )}
71 >
72 {banner.content}
73 </motion.div>
74 )
75 })}
76 </AnimatePresence>
77 </div>
78 </motion.div>
79 )
80}