BannerStack.tsx 2.5 KB

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