复刻小红书Web端打开详情过渡动画
侧边栏壁纸
  • 累计撰写 41 篇文章
  • 累计收到 1 条评论

复刻小红书Web端打开详情过渡动画

七叶
2026-04-29 / 0 阅读 / 正在检测是否收录...

小红书Web端效果展示
先看效果

浏览小红书Web端被这种丝滑的过渡吸引,因此想要复刻这种过渡效果。
首先想到就是利用FLIP动画实现
何为FLIP 动画?
一种动画范式,分为四步完成
First:记录动画元素的初始位置、状态
Last: 移动元素到最终位置,记录元素的最终位置、状态
Invert:计算差异并反向应用,让元素"看起来"还在初始位置
Play:通过动画过渡到最终状态
接下来通过小案例理解上述四步
案例1——方块移动

First:首先记录下元素的初始位置
javascript 体验AI代码助手 代码解读复制代码// 1 First 记录初始状态
const first = box.getBoundingClientRect()

Last:执行DOM变化,并且记录下最终状态
javascript 体验AI代码助手 代码解读复制代码if (isMoved) {
box.classList.remove('moved')
} else {
box.classList.add('moved')
}
isMoved = !isMoved
// 立即获取最终位置,此时元素已经在新的位置,但还没动画
const last = box.getBoundingClientRect()

此时元素的布局位置已经发生变化,但是由于浏览器没有渲染,因此页面上没有体现

Invert: 计算差异并反向应用
javascript 体验AI代码助手 代码解读复制代码const deltaX = first.left - last.left
const deltaY = first.top - last.top
console.log('位置差异:', { deltaX, deltaY })

box.style.transform = translate(${deltaX}px, ${deltaY}px)
box.style.transition = 'none'

这一步是动画核心:在运用translate(deltaXpx,{deltaX}px, deltaXpx,{deltaY}px) 元素已经在视觉上回到了原始位置。
因此用户打开浏览器看到的的方块依然在原地,其实已经经历了 位置左移——》translate回到原地,两个操作
那为啥用户看不到其中的变化呢?因为浏览器会聚合同步代码,放在一帧中渲染。
这也是FLIP动画非常绝妙的地方。
Play:执行动画
javascript 体验AI代码助手 代码解读复制代码requestAnimationFrame(() => {
box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'
box.style.transform = 'none'
})

通过box.style.transform = 'none' 让元素回到布局原点。
完整代码:
html 体验AI代码助手 代码解读复制代码<!DOCTYPE html>

<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FLIP案例1: 单元素移动</title>
<style>
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    background: lightgray;
    min-height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
  }
  .container {
    text-align: center;
  }
  .move-btn {
    padding: 12px 24px;
    font-size: 16px;
    background: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    margin-bottom: 40px;
    transition: transform 0.2s;
  }

  .move-btn:hover {
    transform: scale(1.05);
  }

  .move-btn:active {
    transform: scale(0.95);
  }

  .box {
    width: 120px;
    height: 120px;
    background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
    border-radius: 12px;
    display: flex;
    align-items: center;
    justify-content: center;
    color: white;
    font-size: 18px;
    font-weight: bold;
    box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
    margin-left: 0;
  }

  .box.moved {
    margin-left: calc(100vw - 200px);
  }
</style>


<div class="container">
  <button id="moveBtn" class="move-btn">点击移动方块</button>
  <div id="box" class="box">方块</div>
</div>

<script>
  const moveBtn = document.querySelector('#moveBtn')
  const box = document.querySelector('#box')

  let isMoved = false

  moveBtn.addEventListener('click', () => {
    // ========== FLIP动画的四个步骤 ==========

    // 1 First 记录初始状态
    const first = box.getBoundingClientRect()
    console.log('初始位置:', {
      left: first.left,
      top: first.top,
      width: first.width,
      height: first.height
    })

    // 2 Last 执行DOM变化并记录最终状态
    if (isMoved) {
      box.classList.remove('moved')
    } else {
      box.classList.add('moved')
    }

    isMoved = !isMoved

    // 立即获取最终位置,此时元素已经在新的位置,但还没动画
    const last = box.getBoundingClientRect()
    console.log('最终位置:', {
      left: last.left,
      top: last.top,
      width: last.width,
      height: last.height
    })

    // 3 Invert 计算差异并反向应用
    const deltaX = first.left - last.left
    const deltaY = first.top - last.top
    console.log('位置差异:', { deltaX, deltaY })
    // 此时元素已经被传回了原始位置
    box.style.transform = `translate(${deltaX}px, ${deltaY}px)`
    box.style.transition = 'none'

    // 4 Play 执行动画
    requestAnimationFrame(() => {
      box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'
      box.style.transform = 'none'
    })

    // 动画结束 回收inline style
    box.addEventListener(
      'transitionend',
      function cleanup() {
        box.style.transition = ''
        box.style.transform = 'none'
        box.removeEventListener('transitionend', cleanup)
      },
      { once: true }
    )
  })
</script>


适用范围
肯定有人觉得不是直接通过translate移动就行了么?没错。这个案例只是让你了解FLIP动画的范式
FLIP动画有它自己的适用范围,例如:

列表排序/过滤:删掉一项后其他项自动补位,每项偏移量不同,你算不过来
布局切换:比如从网格视图切到列表视图,每个元素位置都变了

这些场景的共同点是:你改了 DOM 或 CSS 类之后,让浏览器布局引擎算出新位置,然后 FLIP 帮你把这个"瞬间跳变"变成平滑动画。
小红书过渡复刻
首先是页面静态样式
html 体验AI代码助手 代码解读复制代码<!doctype html>

<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小红书页面切换动画</title>
<style>
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    background: #f5f5f5;
    padding: 20px;
  }

  h1 {
    text-align: center;
    font-size: 22px;
    color: #333;
    margin-bottom: 4px;
  }
  .tip {
    text-align: center;
    font-size: 13px;
    color: #999;
    margin-bottom: 20px;
  }
  /* ====== 卡片列表 - 最简单的flex排列 ====== */
  .grid {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
    justify-content: center;
  }

  /* ====== 卡片 ====== */
  .card {
    width: 220px;
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    cursor: pointer;
  }
  .card-image img {
    display: block;
    width: 100%;
  }
  .card-title {
    padding: 10px 12px;
    font-size: 13px;
    color: #333;
  }
  /* ====== 遮罩 ====== */
  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.65);
    z-index: 100;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.35s ease;
  }
  .overlay.visible {
    opacity: 1;
    pointer-events: auto;
  }
  /* ====== 详情弹窗 - 左图右文的简单布局 ====== */
  .detail {
    position: fixed;
    z-index: -1;
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    visibility: hidden;
  }
  .detail.visible {
    display: flex;
    z-index: 101;
    visibility: visible;
    inset: 0;
    margin: auto;
    width: fit-content;
    height: 600px;
  }
  /* 弹窗左侧 - 图片 */
  .detail-img {
    background: #f7f7f7;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .detail-img img {
    width: auto;
    max-width: 600px;
    height: 100%;
    object-fit: contain;
    display: block;
  }

  /* 弹窗右侧  */
  .detail-body {
    width: 0;
    padding: 24px;
    overflow-y: auto;
    transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .detail-body.visible {
    width: 300px;
  }

  .detail-body h2 {
    font-size: 18px;
    color: #333;
    margin-bottom: 12px;
  }

  .detail-body p {
    font-size: 14px;
    color: #555;
    line-height: 1.8;
    white-space: pre-wrap;
  }

  /* 关闭按钮 */
  .close-btn {
    position: absolute;
    top: 12px;
    right: 12px;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: none;
    background: rgba(0, 0, 0, 0.4);
    color: #fff;
    font-size: 18px;
    cursor: pointer;
    z-index: 10;
    display: flex;
    align-items: center;
    justify-content: center;
  }
</style>


<h1>小红书卡片展开动画</h1>
<p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p>

<!-- 卡片列表 动态插入 -->
<div class="grid" id="grid"></div>

<!-- 详情页 -->
<!-- 遮罩 -->
<div class="overlay" id="overlay"></div>
<!-- 详情 -->
<div class="detail" id="detail">
  <button class="close-btn" id="closeBtn">&times;</button>
  <div class="detail-img" id="detailImgWrapper">
    <img id="detailImgEl" src="" alt="" />
  </div>
  <div class="detail-body" id="detailBody">
    <h2 id="detailTitle"></h2>
    <p id="detailDesc"></p>
  </div>
</div>

<script>
  const cards = [
    {
      image: '../imgs/test.jpg',
      title: '春日穿搭分享',
      desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感'
    },
    {
      image: '../imgs/31-400x600.jpg',
      title: '咖啡拉花教程',
      desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸'
    },
    {
      image: '../imgs/451-400x400.jpg',
      title: '周末野餐攻略',
      desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水'
    },
    {
      image: '../imgs/507-400x550.jpg',
      title: '北欧风客厅改造',
      desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内'
    },
    {
      image: '../imgs/1008-400x520.jpg',
      title: '健康早餐食谱',
      desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!'
    },
    {
      image: '../imgs/825-400x650.jpg',
      title: '绝美日落合集',
      desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!'
    }
  ]
  const gridEl = document.querySelector('#grid')
  // 渲染卡片列表
  cards.forEach((card) => {
    const el = document.createElement('div')
    el.className = 'card'
    el.innerHTML = `
            <div class="card-image"><img src="${card.image}" alt=""></div>
            <div class="card-title">${card.title}</div>
          `
    el.addEventListener('click', () => open(el, card))
    gridEl.appendChild(el)
  })
</script>


这里注意详情页中图片使用object-fit: contain保障了横图或者竖图总能完整呈现
按步骤拆解
First:首先将详情页定位到点击的卡片图片处,并且长宽与图片一致
javascript 体验AI代码助手 代码解读复制代码// 点击卡片的【封面图】
const innerCardEl = cardEl.querySelector('.card-image')
activeCardEl = innerCardEl
overlayEl.classList.add('visible') // 开启遮罩层
detailBodyEl.classList.add('visible') // 内容区展开

// 填充详情页内容
detailImgEl.src = cardData.image
detailTitleEl.textContent = cardData.title
detailDescEl.textContent = cardData.desc

// First - 记录卡片在页面中的位置
const firstRect = innerCardEl.getBoundingClientRect()

Last:移动DOM,并且记录下最终的状态
javascript 体验AI代码助手 代码解读复制代码// Last - 让详情页以最终状态显示,获取最终位置
detailEl.classList.add('visible')
detailEl.offsetHeight
const lastRect = detailEl.getBoundingClientRect()

Invert:通过transform逆向移动到原始位置,让详情页看起来没用发生概念
javascript 体验AI代码助手 代码解读复制代码// Invert - 从最终位置反推回卡片位置
const deltaX = firstRect.left - lastRect.left
const deltaY = firstRect.top - lastRect.top
const deltaW = firstRect.width / lastRect.width
const deltaH = firstRect.height / lastRect.height

detailEl.style.transformOrigin = 'top left'
detailEl.style.transform = translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})

Play:开始动画
javascript 体验AI代码助手 代码解读复制代码// Play - 动画回到最终位置
requestAnimationFrame(() => {
requestAnimationFrame(() => {

detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
detailEl.style.transform = 'none'

detailEl.addEventListener(
  'transitionend',
  () => {
    detailEl.style.transition = ''
    detailEl.style.transform = ''
    detailEl.style.transformOrigin = ''
  },
  { once: true }
)

})
})

关闭的过渡,就是打开的逆向过程
javascript 体验AI代码助手 代码解读复制代码 function close() {
if (!activeCardEl) return
overlayEl.classList.remove('visible')

// First - 详情页当前位置(居中状态)
const firstRect = detailEl.getBoundingClientRect()

// Last - 目标是回到卡片位置
const lastRect = activeCardEl.getBoundingClientRect()

// Invert - 从当前居中位置出发,计算到卡片位置的变换
const deltaX = lastRect.left - firstRect.left
const deltaY = lastRect.top - firstRect.top
const deltaW = lastRect.width / firstRect.width
const deltaH = lastRect.height / firstRect.height

detailEl.style.transformOrigin = 'top left'
detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
detailEl.style.transform = translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})

detailEl.addEventListener(

'transitionend',
() => {
  detailEl.classList.remove('visible')
  detailBodyEl.classList.remove('visible')
  detailEl.style.transition = ''
  detailEl.style.transform = ''
  detailEl.style.transformOrigin = ''
  activeCardEl = null
},
{ once: true }

)
}

完整代码:
html 体验AI代码助手 代码解读复制代码<!doctype html>

<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>小红书页面切换动画</title>
<style>
  * {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
  }

  body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
    background: #f5f5f5;
    padding: 20px;
  }

  h1 {
    text-align: center;
    font-size: 22px;
    color: #333;
    margin-bottom: 4px;
  }
  .tip {
    text-align: center;
    font-size: 13px;
    color: #999;
    margin-bottom: 20px;
  }
  /* ====== 卡片列表 - 最简单的flex排列 ====== */
  .grid {
    display: flex;
    flex-wrap: wrap;
    gap: 16px;
    justify-content: center;
  }

  /* ====== 卡片 ====== */
  .card {
    width: 220px;
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    cursor: pointer;
  }
  .card-image img {
    display: block;
    width: 100%;
  }
  .card-title {
    padding: 10px 12px;
    font-size: 13px;
    color: #333;
  }
  /* ====== 遮罩 ====== */
  .overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.65);
    z-index: 100;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.35s ease;
  }
  .overlay.visible {
    opacity: 1;
    pointer-events: auto;
  }
  /* ====== 详情弹窗 - 左图右文的简单布局 ====== */
  .detail {
    position: fixed;
    z-index: -1;
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    visibility: hidden;
  }
  .detail.visible {
    display: flex;
    z-index: 101;
    visibility: visible;
    inset: 0;
    margin: auto;
    width: fit-content;
    height: 600px;
  }
  /* 弹窗左侧 - 图片 */
  .detail-img {
    background: #f7f7f7;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .detail-img img {
    width: auto;
    max-width: 600px;
    height: 100%;
    object-fit: contain;
    display: block;
  }

  /* 弹窗右侧  */
  .detail-body {
    width: 0;
    padding: 24px;
    overflow-y: auto;
    transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .detail-body.visible {
    width: 300px;
  }

  .detail-body h2 {
    font-size: 18px;
    color: #333;
    margin-bottom: 12px;
  }

  .detail-body p {
    font-size: 14px;
    color: #555;
    line-height: 1.8;
    white-space: pre-wrap;
  }

  /* 关闭按钮 */
  .close-btn {
    position: absolute;
    top: 12px;
    right: 12px;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: none;
    background: rgba(0, 0, 0, 0.4);
    color: #fff;
    font-size: 18px;
    cursor: pointer;
    z-index: 10;
    display: flex;
    align-items: center;
    justify-content: center;
  }
</style>


<h1>小红书卡片展开动画</h1>
<p class="tip">点击卡片,观察图片从列表位置平滑展开到弹窗的过渡效果</p>

<!-- 卡片列表 动态插入 -->
<div class="grid" id="grid"></div>

<!-- 详情页 -->
<!-- 遮罩 -->
<div class="overlay" id="overlay"></div>
<!-- 详情 -->
<div class="detail" id="detail">
  <button class="close-btn" id="closeBtn">&times;</button>
  <div class="detail-img" id="detailImgWrapper">
    <img id="detailImgEl" src="" alt="" />
  </div>
  <div class="detail-body" id="detailBody">
    <h2 id="detailTitle"></h2>
    <p id="detailDesc"></p>
  </div>
</div>

<script>
  const cards = [
    {
      image: '../imgs/test.jpg',
      title: '春日穿搭分享',
      desc: '米色针织开衫搭配白色半身裙,\n既舒适又显气质。\n\n搭配要点:\n1. 柔和色调营造温柔感\n2. 针织材质增添春日气息\n3. 配饰简约,突出整体感'
    },
    {
      image: '../imgs/31-400x600.jpg',
      title: '咖啡拉花教程',
      desc: '在家制作拉花其实不难!\n\n步骤:\n1. 制作浓缩咖啡基底\n2. 打发牛奶至细腻光滑\n3. 从中心注入,控制流速\n4. 轻轻摇晃拉花缸'
    },
    {
      image: '../imgs/451-400x400.jpg',
      title: '周末野餐攻略',
      desc: '必带物品:\n- 防水野餐垫\n- 保温箱\n- 便携餐具\n- 遮阳伞\n\n食物推荐:\n三明治、水果拼盘、气泡水'
    },
    {
      image: '../imgs/507-400x550.jpg',
      title: '北欧风客厅改造',
      desc: '设计要点:\n1. 白灰为主色调\n2. 简洁线条家具\n3. 多层次照明\n4. 绿植增添生机\n\n总花费控制在15k以内'
    },
    {
      image: '../imgs/1008-400x520.jpg',
      title: '健康早餐食谱',
      desc: '推荐搭配:\n- 全麦面包 + 煎蛋 + 牛油果\n- 燕麦粥 + 坚果 + 蓝莓\n\n制作时间都在15分钟内!'
    },
    {
      image: '../imgs/825-400x650.jpg',
      title: '绝美日落合集',
      desc: '拍摄技巧:\n1. 日落前30分钟(黄金时段)\n2. 剪影构图\n3. 白平衡偏暖\n4. 低角度拍摄\n\n器材:手机就够了!'
    }
  ]

  const detailHeight = 742 // 详情页固定高度

  const gridEl = document.querySelector('#grid')
  // 渲染卡片列表
  cards.forEach((card) => {
    const el = document.createElement('div')
    el.className = 'card'
    el.innerHTML = `
            <div class="card-image"><img src="${card.image}" alt=""></div>
            <div class="card-title">${card.title}</div>
          `
    el.addEventListener('click', () => open(el, card))
    gridEl.appendChild(el)
  })

  const overlayEl = document.querySelector('#overlay')
  const detailEl = document.querySelector('#detail')
  const detailImgEl = document.querySelector('#detailImgEl')
  const detailTitleEl = document.querySelector('#detailTitle')
  const detailDescEl = document.querySelector('#detailDesc')
  const closeBtnEl = document.querySelector('#closeBtn')
  const detailBodyEl = document.querySelector('#detailBody')

  let activeCardEl = null

  // 点击卡片打开详情
  function open(cardEl, cardData) {
    const innerCardEl = cardEl.querySelector('.card-image')
    activeCardEl = innerCardEl
    overlayEl.classList.add('visible')
    detailBodyEl.classList.add('visible')

    detailImgEl.src = cardData.image
    detailTitleEl.textContent = cardData.title
    detailDescEl.textContent = cardData.desc

    // First - 记录卡片在页面中的位置
    const firstRect = innerCardEl.getBoundingClientRect()

    // Last - 让详情页以最终状态显示,获取最终位置
    detailEl.classList.add('visible')
    detailEl.offsetHeight
    const lastRect = detailEl.getBoundingClientRect()

    // Invert - 从最终位置反推回卡片位置
    const deltaX = firstRect.left - lastRect.left
    const deltaY = firstRect.top - lastRect.top
    const deltaW = firstRect.width / lastRect.width
    const deltaH = firstRect.height / lastRect.height

    detailEl.style.transformOrigin = 'top left'
    detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`

    // Play - 动画回到最终位置
    requestAnimationFrame(() => {
      requestAnimationFrame(() => {
        detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
        detailEl.style.transform = 'none'

        detailEl.addEventListener(
          'transitionend',
          () => {
            detailEl.style.transition = ''
            detailEl.style.transform = ''
            detailEl.style.transformOrigin = ''
          },
          { once: true }
        )
      })
    })
  }

  function close() {
    if (!activeCardEl) return
    overlayEl.classList.remove('visible')

    // First - 详情页当前位置(居中状态)
    const firstRect = detailEl.getBoundingClientRect()

    // Last - 目标是回到卡片位置
    const lastRect = activeCardEl.getBoundingClientRect()

    // Invert - 从当前居中位置出发,计算到卡片位置的变换
    const deltaX = lastRect.left - firstRect.left
    const deltaY = lastRect.top - firstRect.top
    const deltaW = lastRect.width / firstRect.width
    const deltaH = lastRect.height / firstRect.height

    detailEl.style.transformOrigin = 'top left'
    detailEl.style.transition = 'transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)'
    detailEl.style.transform = `translate(${deltaX}px, ${deltaY}px) scale(${deltaW}, ${deltaH})`

    detailEl.addEventListener(
      'transitionend',
      () => {
        detailEl.classList.remove('visible')
        detailBodyEl.classList.remove('visible')
        detailEl.style.transition = ''
        detailEl.style.transform = ''
        detailEl.style.transformOrigin = ''
        activeCardEl = null
      },
      { once: true }
    )
  }

  closeBtnEl.addEventListener('click', close)
  overlayEl.addEventListener('click', close)
</script>