
红包这个交互形态做前端和做动效的人几乎都碰过。但真正把翻转可拆这四个字做扎实的项目并不多——大部分实现要么翻转生硬像翻书要么拆开之后红包就死在那里没有后续反馈。我最近完整做了一版红包动画核心诉求就两个翻转要顺、拆开要有惊喜感。这篇文章把整个实现过程拆开讲包括我为什么选这套方案、翻转的数学原理、拆开瞬间的粒子与形变处理以及我在真机测试时踩到的几个坑。如果你正在做营销活动页、节日互动、或者任何需要点击—翻转—拆开—反馈这套链路的场景这篇内容可以直接抄作业。我会尽量把每个参数为什么这么设讲清楚而不是只丢一段代码给你。1. 先想清楚翻转可拆到底在翻什么很多人一上来就写 CSStransform: rotateY(180deg)写完发现红包翻过去之后背面是空的或者翻到一半卡住。问题出在没想清楚翻转的对象和层级。1.1 翻转的不是红包是封套和内页两个面红包在物理世界里是一个信封结构正面是封套有图案、有封口背面是空的里面装着钱或卡片。用户点击拆的时候实际发生的是封套向上翻开露出里面的内容。所以正确的结构不是一个 div 翻转 180 度而是一个容器redpacket里面放两个面front封套正面和back内页/奖品两个面通过backface-visibility: hidden控制背面不可见容器做rotateY旋转转到 180 度时back面朝前这里有个关键点back面本身要预先旋转 180 度否则翻过去之后它是镜像的。这是新手最容易漏的一步。.redpacket { perspective: 1000px; } .redpacket-inner { transform-style: preserve-3d; transition: transform 0.8s cubic-bezier(0.4, 0.0, 0.2, 1); } .redpacket.flipped .redpacket-inner { transform: rotateY(180deg); } .face { position: absolute; backface-visibility: hidden; } .face.back { transform: rotateY(180deg); }perspective: 1000px这个值不是随便写的。它决定了翻转的透视强度——值越小翻转时近大远小的畸变越明显看起来越立体但也越夸张值越大越接近正交投影翻转显得平。1000px 是我实测下来在移动端最舒服的区间红包这种小尺寸元素用 800~1200px 都不会出错。1.2 为什么用 rotateY 而不是 rotateX翻转方向的选择直接影响拆的语义。rotateY是绕垂直轴翻转视觉上像翻书、像掀开一页rotateX是绕水平轴翻转像翻盖、像掀桌板。红包的物理动作是从封口处向上掀开更接近rotateX的语义。但纯rotateX在移动端有个问题翻转过程中红包会变矮视觉重心上移配合下方的按钮会显得脱节。我的处理是主翻转用 rotateY但在翻转过程中叠加一个轻微的 rotateX 和位移模拟掀起来再翻过去的复合动作。具体做法是在关键帧里分两段keyframes flipOpen { 0% { transform: rotateY(0deg) rotateX(0deg) translateY(0); } 40% { transform: rotateY(90deg) rotateX(-8deg) translateY(-12px); } 100% { transform: rotateY(180deg) rotateX(0deg) translateY(0); } }40% 那一帧是整个动画的灵魂帧——此时红包正好侧对屏幕是最薄的一瞬间加一点rotateX和上移会让它看起来像被捏起来翻过去而不是原地打转。这个细节很多教程不会讲但加上之后质感提升非常明显。1.3 翻转时长与缓动的取舍翻转时长我试过 0.4s、0.6s、0.8s、1.2s 四档时长感受适用场景0.4s太快看不清翻转过程不适合像闪一下0.6s干脆利落高频操作、批量拆包0.8s有仪式感节奏舒服单次拆红包推荐1.2s拖沓用户会等得不耐烦只适合开场大动画缓动函数用cubic-bezier(0.4, 0.0, 0.2, 1)这是 Material Design 的标准缓动起步快、收尾稳翻转这种有明确终点的动作特别适合。如果你想要更弹一点的手感可以在结尾加一个回弹但红包场景我不建议——拆红包要的是确定感回弹会让人觉得没拆干净。2. 拆开瞬间的反馈设计从翻开到炸开翻转只是前戏真正让用户哇一声的是拆开的那一下。这里我把拆开拆成三个层次封套分离、内容弹出、粒子扩散。2.1 封套分离让盖子真的飞出去如果只是翻转 180 度露出背面用户会觉得就这。物理世界里拆红包封套是会离开主体的。所以我在翻转完成后让front面封套做一个向上向外旋转的飞出动画同时降低透明度。keyframes flyAway { 0% { transform: translateY(0) rotateZ(0) scale(1); opacity: 1; } 100% { transform: translateY(-120px) rotateZ(-18deg) scale(0.9); opacity: 0; } }translateY(-120px)这个距离要配合红包尺寸。我的红包主体是 200px 高飞出去 120px 大约是自身高度的 60%视觉上飞走了但不会飞出屏幕。rotateZ(-18deg)给一点倾斜避免直上直下显得机械。透明度在最后 30% 才快速降到 0让用户看清它飞出去的过程。注意封套飞出后要设pointer-events: none否则它会挡住下面内容的点击。这个坑我在真机上踩过——封套透明了但还在原地用户点奖品点不动。2.2 内容弹出用 scale 位移制造蹦出来的感觉内页奖品/金额不能只是露出来要蹦出来。我用的是scale从 0.6 到 1.05 再到 1 的两段式配合一个向上的位移。keyframes popOut { 0% { transform: scale(0.6) translateY(20px); opacity: 0; } 60% { transform: scale(1.05) translateY(-6px); opacity: 1; } 100% { transform: scale(1) translateY(0); opacity: 1; } }60% 那一帧的scale(1.05)是过冲让内容看起来有弹性。这个过冲量不能大1.05 到 1.08 之间最舒服超过 1.1 就会显得廉价。位移translateY(-6px)是配合过冲的让弹的方向朝上符合从红包里冒出来的直觉。2.3 粒子扩散金币/星星的批量动画粒子是拆红包的气氛组。我的做法是预生成 12~16 个粒子元素每个粒子有随机的角度、距离、旋转和延迟用 CSS 变量传入关键帧。const particles []; for (let i 0; i 14; i) { const angle (Math.PI * 2 * i) / 14 Math.random() * 0.4; const distance 80 Math.random() * 60; const el document.createElement(div); el.className particle; el.style.setProperty(--tx, ${Math.cos(angle) * distance}px); el.style.setProperty(--ty, ${Math.sin(angle) * distance}px); el.style.setProperty(--rot, ${Math.random() * 360}deg); el.style.setProperty(--delay, ${Math.random() * 0.15}s); particles.push(el); }keyframes burst { 0% { transform: translate(0, 0) rotate(0) scale(0); opacity: 1; } 70% { opacity: 1; } 100% { transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1); opacity: 0; } }粒子数量控制在 12~16 个是有讲究的。少于 10 个显得冷清多于 20 个在低端安卓机上会掉帧。距离 80~140px 是围绕红包主体的合理扩散范围太近像没炸开太远会飞出可视区域。提示粒子用will-change: transform提前提升为合成层能显著减少动画期间的卡顿。但动画结束后记得移除这个属性否则会一直占用 GPU 内存。3. 状态机设计别让用户把红包点坏红包动画最怕的就是用户手快连点。翻转还没结束又点一下或者拆开后又点状态就乱了。我用一个简单的状态机来管。3.1 四个状态与合法转移红包只有四个状态idle待拆、flipping翻转中、opened已拆开、done动画结束。合法转移只有一条链idle → flipping → opened → done任何反向或跳跃转移都要被拦截。实现上就是一个变量加判断let state idle; function handleClick() { if (state ! idle) return; state flipping; playFlipAnimation().then(() { state opened; playBurstAnimation().then(() { state done; }); }); }state ! idle这一句就挡住了所有连点。看起来简单但很多项目就是漏了这一句导致用户连点后动画叠加、元素错位。3.2 用 animationend 而不是 setTimeout判断动画结束我强烈建议用animationend事件而不是setTimeout。原因有两个setTimeout的时间是写死的一旦你调整了动画时长忘了改定时器逻辑就错位页面切到后台时setTimeout可能被节流而animationend更可靠element.addEventListener(animationend, (e) { if (e.animationName flipOpen) { // 翻转结束进入下一阶段 } });如果动画是多个元素并行的记得用e.animationName过滤否则任何一个子动画结束都会触发回调。3.3 中断恢复用户切后台再回来怎么办移动端有个真实场景用户拆到一半切到微信回消息再切回来。这时候动画可能已经跑完了但状态没更新或者停在中间。我的处理是监听visibilitychange页面重新可见时检查状态如果处于flipping或opened的中间态直接跳到done并展示最终结果。宁可跳过动画也不能让用户看到一个卡住的半成品。document.addEventListener(visibilitychange, () { if (!document.hidden (state flipping || state opened)) { finishImmediately(); } });4. 真机测试踩到的五个坑这部分是我觉得最有价值的内容因为都是文档里不会写、只有真跑起来才会遇到的问题。4.1 iOS Safari 的 3D 翻转闪烁在 iOS Safari 上preserve-3d配合backface-visibility: hidden时翻转过程中偶尔会出现面闪烁——两个面同时可见一瞬间。这是 Safari 的合成层 bug。解决方案是给两个面都加上transform: translateZ(0)强制它们各自独立成层.face { transform: translateZ(0); backface-visibility: hidden; } .face.back { transform: rotateY(180deg) translateZ(0); }注意back面的translateZ(0)要加在rotateY之后顺序不能反否则会改变翻转轴。4.2 低端安卓机的粒子掉帧我在一台骁龙 4 系的老机器上测14 个粒子同时动画时帧率掉到 30 以下。排查后发现瓶颈不在粒子数量而在每个粒子都用了box-shadow。去掉box-shadow改用纯色或渐变背景帧率立刻回到 55。如果一定要发光效果用一张预渲染的 PNG 贴图代替box-shadow性能好得多。4.3 翻转时的文字模糊翻转过程中如果红包上有文字rotateY到 90 度附近时文字会糊。这是浏览器在 3D 变换时对文本的抗锯齿处理导致的无法完全避免但可以缓解给文字容器加transform: translateZ(1px)让它略微浮在面上或者干脆在翻转过程中把文字透明度降到 0翻完再淡入我选的是后者因为翻转时用户注意力在翻这个动作上文字消失反而更干净。4.4 点击热区与视觉不一致红包通常有圆角和不规则形状但div的点击热区是矩形的。用户点圆角外面的空白也能触发体验很怪。解决办法是用clip-path裁剪出红包形状同时它也会裁剪点击热区.redpacket { clip-path: polygon(0 0, 100% 0, 100% 85%, 50% 100%, 0 85%); }这个多边形模拟了红包底部中间的尖角。裁剪后点击热区就和视觉一致了。注意clip-path在部分老版本浏览器上不支持需要做降级——不支持时就不裁剪接受矩形热区。4.5 动画结束后的内存泄漏粒子元素是动态创建的如果每次拆红包都创建一批而不清理反复触发会累积大量 DOM 节点。我的做法是动画结束后统一移除粒子Promise.all(particleAnimations).then(() { particles.forEach(p p.remove()); particles.length 0; });同时把will-change也清掉。这个细节在单次拆红包的场景下看不出来但如果你的页面支持再拆一个不清理就会越来越卡。5. 让动画可配置参数化设计思路做到这里功能已经完整了但我又往前走了一步——把关键参数抽出来做成配置这样同一套动画能复用到不同活动。5.1 抽出哪些参数我抽了这几组参数含义默认值flipDuration翻转时长800msflipEasing翻转缓动cubic-bezier(0.4,0,0.2,1)particleCount粒子数量14particleDistance粒子扩散距离80~140pxburstDelay拆开延迟200mstheme主题色红金burstDelay这个参数容易被忽略。翻转刚结束就立刻炸开会显得很赶加 150~250ms 的停顿让用户看清红包翻过来了再炸开节奏感完全不同。我实测 200ms 是最舒服的。5.2 用 CSS 变量而不是 JS 配置参数化我优先用 CSS 变量而不是在 JS 里拼字符串。原因是 CSS 变量能被浏览器原生优化而且改主题色、改时长不用重新计算样式。:root { --flip-duration: 800ms; --flip-easing: cubic-bezier(0.4, 0, 0.2, 1); --particle-distance: 120px; } .redpacket-inner { transition: transform var(--flip-duration) var(--flip-easing); }JS 只负责在需要时改这些变量的值document.documentElement.style.setProperty(--flip-duration, 600ms);这样切换快速模式和仪式感模式只需要改一个变量动画逻辑完全不用动。5.3 主题切换的坑主题色切换时如果粒子用的是background: var(--theme-color)切换瞬间已经在飞的粒子会突然变色很突兀。我的处理是粒子创建时就把颜色写死到元素上而不是引用变量el.style.background getComputedStyle(document.documentElement) .getPropertyValue(--theme-color);这样每个粒子出生时就固定了颜色飞行过程中不受主题切换影响。6. 性能与兼容性的最后一道关动画做完还得过性能和兼容性这两关否则上线就是事故。6.1 只动 transform 和 opacity这是动画性能的铁律。我的整个红包动画所有关键帧里只出现transform和opacity两个属性没有任何width、height、top、left、margin的动画。这两个属性由合成器处理不触发重排重绘能稳定跑在 60fps。如果你需要变大效果用scale而不是width需要移动用translate而不是top/left。这个原则在红包这种元素多、动画密集的场景里尤其重要。6.2 用 transform-origin 控制翻转轴心默认transform-origin是元素中心翻转时红包绕中心转。但物理红包是绕底边或封口边翻的。改transform-origin能让翻转更真实.redpacket-inner { transform-origin: center bottom; }center bottom让红包绕底边翻转像真的从底部掀起来。不过要注意改了轴心之后rotateY的视觉效果会变需要重新调perspective的值来匹配。6.3 降级方案不支持 3D 变换怎么办虽然现在主流浏览器都支持 3D 变换但保险起见我做了降级检测CSS.supports(transform-style, preserve-3d)不支持时改用淡入淡出 缩放代替翻转。if (!CSS.supports(transform-style, preserve-3d)) { // 降级不做 3D 翻转用 opacity scale 模拟 element.classList.add(fallback-mode); }降级模式下红包不翻转而是正面淡出、背面淡入配合一个轻微的缩放。虽然少了立体感但功能完整不会白屏。6.4 减少动画偏好系统级的减少动态效果设置要尊重。用prefers-reduced-motion媒体查询对这类用户直接跳过动画展示最终状态media (prefers-reduced-motion: reduce) { .redpacket-inner { transition: none; } .particle { display: none; } }这不是可选项是必选项。有些用户对动效敏感强行播放动画是不友好的。7. 我在这个项目里最看重的三个细节做完整个红包动画回头看真正决定质感的不是那些大动作而是三个容易被忽略的小细节。第一个是翻转的灵魂帧。前面提到的 40% 那一帧加rotateX和上移是整个动画从能用到好看的分水岭。没有它翻转就是干巴巴的原地打转有了它红包像被一只手捏起来翻过去。这个细节我调了大概七八版才找到舒服的角度rotateX(-8deg)和translateY(-12px)是反复对比后的结果。第二个是拆开前的 200ms 停顿。这个停顿让翻转和炸开两个动作有了呼吸感。我一开始是翻转完立刻炸测试时同事说太快了没看清加上停顿后反馈立刻变好。节奏这件事差 100ms 感受就完全不同。第三个是粒子的随机性。如果所有粒子角度均匀、距离一致看起来就像机械的放射线。我给每个粒子加了 ±0.4 弧度的角度扰动和 ±60px 的距离扰动让它们散得自然。随机种子不用固定每次拆开都不一样反而更有惊喜感。这三个细节加起来代码量不到 30 行但对最终效果的影响超过 70%。做动效这件事很多时候不是堆功能而是把几个关键帧调到位。最后分享一个我常用的调试技巧把动画时长临时放大 5 倍比如 800ms 改成 4000ms用慢动作看每一帧的过渡能发现很多正常速度下看不到的瑕疵比如某个元素提前出现、某个位移方向不对。调完再改回来效率比反复快放高得多。