复刻小红书Web端打开详情过渡动画
小红书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({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动画有它自己的适用范围,例如:
- 列表排序/过滤:删掉一项后其他项自动补位,每项偏移量不同,你算不过来
- 布局切换:比如从网格视图切到列表视图,每个元素位置都变了
这些场景的共同点是:你改了 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">×</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">×</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>