普通视图

发现新文章,点击刷新页面。
今天 — 2026年2月20日首页

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

作者 Soulkey
2026年2月20日 16:11

小红书Web端效果展示

先看效果

浏览小红书Web端被这种丝滑的过渡吸引,因此想要复刻这种过渡效果。

首先想到就是利用FLIP动画实现

何为FLIP 动画?

一种动画范式,分为四步完成

First:记录动画元素的初始位置、状态

Last: 移动元素到最终位置,记录元素的最终位置、状态

Invert:计算差异并反向应用,让元素"看起来"还在初始位置

Play:通过动画过渡到最终状态

接下来通过小案例理解上述四步

案例1——方块移动

First:首先记录下元素的初始位置

// 1 First 记录初始状态
const first = box.getBoundingClientRect()

Last:执行DOM变化,并且记录下最终状态

if (isMoved) {
  box.classList.remove('moved')
} else {
  box.classList.add('moved')
}
isMoved = !isMoved
// 立即获取最终位置,此时元素已经在新的位置,但还没动画
const last = box.getBoundingClientRect()

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

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'

这一步是动画核心:在运用translate(deltaXpx,{deltaX}px, {deltaY}px) 元素已经在视觉上回到了原始位置。

因此用户打开浏览器看到的的方块依然在原地,其实已经经历了 位置左移——》translate回到原地,两个操作

那为啥用户看不到其中的变化呢?因为浏览器会聚合同步代码,放在一帧中渲染。

这也是FLIP动画非常绝妙的地方。

Play:执行动画

requestAnimationFrame(() => {
  box.style.transition = 'transform 0.5s cubic-bezier(0.4, 0, 0.2, 1)'
  box.style.transform = 'none'
})

通过box.style.transform = 'none' 让元素回到布局原点。

完整代码:

<!DOCTYPE html>
<html lang="en">
  <head>
    <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>
  </head>
  <body>
    <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>
  </body>
</html>

适用范围

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

FLIP动画有它自己的适用范围,例如:

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

这些场景的共同点是:你改了 DOM 或 CSS 类之后,让浏览器布局引擎算出新位置,然后 FLIP 帮你把这个"瞬间跳变"变成平滑动画。

小红书过渡复刻

首先是页面静态样式

<!doctype html>
<html lang="en">
  <head>
    <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>
  </head>
  <body>
    <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>
  </body>
</html>

这里注意详情页中图片使用object-fit: contain保障了横图或者竖图总能完整呈现

按步骤拆解

First:首先将详情页定位到点击的卡片图片处,并且长宽与图片一致

// 点击卡片的【封面图】
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,并且记录下最终的状态

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

Invert:通过transform逆向移动到原始位置,让详情页看起来没用发生概念

// 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:开始动画

// 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 }
  )
}

完整代码:

<!doctype html>
<html lang="en">
  <head>
    <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>
  </head>
  <body>
    <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>
  </body>
</html>

源码

gitee.com/soulkey3/fl…

❌
❌