ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

CSS 3D翻页时钟实战:从原理到动效打磨的完整指南

CSS 3D翻页时钟实战:从原理到动效打磨的完整指南 第一次在手头的项目里看到翻页时钟的效果时我盯着那个数字“啪”地翻过去的过程看了很久。数字时钟本身没什么稀奇的但那个“翻”的动作像老式火车站时刻表一样带着一种机械的仪式感让时间流动变得肉眼可见。后来自己动手实现一遍才发现这个翻页效果卡在一个很微妙的位置它不涉及复杂的算法但如果你不理解CSS 3D属性在视觉上的分工不处理好JS时间状态和DOM动画的同步做出来的东西很容易变成“假翻页”——数字从中间突然由旧变新完全没有翻牌的味道。这篇文章我打算把整个实现链路拆开讲清楚从DOM结构怎么搭、CSS 3D的透视与旋转怎么配合到时间数据流怎么驱动每次翻页再到初始化、进位、后台切回这些边界情况怎么处理最后是让翻转更有质感的动效细节。内容偏向实战适合已经会基础HTML/CSS/JS、想做一个能真正跑起来的翻页时钟的人。你按这个思路走做出来的效果至少能骗过大多数人的眼睛。1. 翻页时钟的视觉欺骗四层牌面的叠加与翻转分工1.1 拆开“翻牌”这个动作上下两半为什么不同步翻页时钟的核心难点不在“转”而在“如何让翻转看起来像真的”。现实中那种翻牌钟数字不是一整张牌直接翻过去而是牌的上半部分和下半部分分开运动上半部分先从上往下倒下来等它快倒平的时候下半部分才开始从下往上翻起来。整个周期里上下两半不是同时动的而是一前一后接力完成。如果用一整张牌做rotateX旋转问题很明显转90度的时候牌面刚好垂直于屏幕变成一条线继续转你会看到牌的背面——不是“下面的旧数字被新数字取代”而是“眼前这张牌露馅了”。真实翻牌钟里的玄机在于前半段你看到的是旧数字上部分在向下倒后半段你看到的是旧数字下部分在向上翻而新数字的上部分其实一直静静躺在最底层只是被前一个动作遮住了。所以纯CSS实现翻页效果时一个数字位至少要拆成四个视觉层新数字的上半部分、新数字的下半部分、旧数字的上半部分负责往下倒、旧数字的下半部分负责往上翻。前两层是常驻的后两层只在翻页动画期间出现。视觉上“新数字逐步取代旧数字”其实是这四层在z轴和3D空间里相互配合的结果。1.2 最小DOM结构与3D透视参数一个数字位的标准结构大概是这样的div classflip-clock__piece !-- 常驻的新数字牌 -- div classflip-card div classtop-half8/div div classbottom-half8/div /div !-- 翻牌动画的两个临时层 -- div classupper-flip7/div div classlower-flip7/div /div简单解释一下.top-half和.bottom-half被裁剪成各自一半的高度拼起来正好是一张完整的数字牌它们始终显示当前最终要停留的新值。.upper-flip和.lower-flip是动画层在翻牌的瞬间显示旧值播放动画时分别执行“倒下”和“翻起”两个动作。动画结束后这两层要清理干净不能留在画面里挡路。实现半高裁剪有个常用的技巧给.flip-card固定高度和overflow: hidden然后让数字文本用绝对定位偏移让上半只露出数字的上半部分下半只露出数字的下半部分。比如卡片高度60px数字用绝对定位以卡片高度为基准top显示在0到30px的区域bottom显示在30px到60px的区域配合line-height就能裁得很干净。整个piece需要加透视perspective这是3D视觉的关键。我一般设在400px左右太小会让翻转时透视变形过于夸张太大则几乎没有景深感看起来像平面硬切。透视只加在父容器上transform-style: preserve-3d要在更内层的容器上设置否则子元素的3D变换会被压平。这块顺序很多人容易搞错perspective在爷爷级preserve-3d在爸爸级rotateX直接作用在数字牌这一级。1.3 为什么不能直接翻转整张牌这个问题我刚做的时候也想过既然上下两半分开翻为什么不干脆做一个半边牌的翻转动画比如只翻转上半部分、只翻转下半部分理论上可以但有一个视觉漏洞如果你只给上半部分做rotateX它旋转超过90度后你会在下半部分还没翻起前看到一个“悬空的、翻转过来的上半牌背面”。这跟真实机械翻牌的结构不符因为真实翻牌里上半倒下后是贴在后方立面上的不会悬空。所以标准做法是上半翻转层在旋转超过约90度后直接变透明或者被裁掉同时下半翻转层从与屏幕垂直的90度位置开始翻。也就是说下半翻转层的初始状态不是平贴在牌面上而是已经“立起来”的。这样在动画中段你看到的视觉瞬间是上半已经不见了下半正从垂直状态翻平而它翻平后露出的就是底层那个常驻的新数字下半部分。整个过程严丝合缝没有任何穿帮。2. 时间数据流设计从Date对象到稳定的状态快照2.1 为什么不能把当前秒数直接当状态很多人写翻页时钟第一版逻辑是setInterval每秒读一次new Date()然后直接改DOM上的文本同时用CSS的animation触发翻牌。这样写最直白但会碰到几个很烦的问题。首先是重复触发问题如果翻页动画用时0.6秒而定时器1秒触发一次动画本身是能播完的但如果某次浏览器卡顿累积了多次定时器回调或者用户在短时间内手动刷新逻辑就会看到同一个数字位被连续触发好几次翻页动画新旧数字在DOM里打架。其次是定时器漂移setInterval不保证每次间隔都是1000ms尤其在笔记本合盖、浏览器标签页后台运行的情况下定时器可能被大幅节流。切回来的时候Date.now()跳了好几分钟如果你直接用当前秒数去跟旧值做对比会发现中间所有没执行的翻页瞬间全丢了或者触发一连串尴尬的动画补放。正确思路是把“时间数据”和“动画渲染”解耦。数据流是唯一的每次定时器唤醒时读取时间和上一次的状态快照做diff只有某个数字位真的变了才去操作那个位的DOM并触发动画。状态是数据的快照DOM只是状态的投影。2.2 状态快照与diff触发的代码结构我习惯维护一个全局状态对象结构很简单const state { previous: { hour: 0, minute: 0, second: 0 }, current: { hour: 0, minute: 0, second: 0 } };每次tick先把current赋给previous再从new Date()里读取新的时分秒赋值给current。然后逐个数字位去比较previous和current的对应数字function updateClock(date) { const hour date.getHours(); const minute date.getMinutes(); const second date.getSeconds(); // 保存旧状态 state.previous { ...state.current }; state.current { hour, minute, second }; // 逐位比较只更新发生变化的位 const units [ { selector: .piece-hour-tens, prev: String(parseInt(state.previous.hour / 10)), curr: String(parseInt(state.current.hour / 10)) }, { selector: .piece-hour-ones, prev: String(state.previous.hour % 10), curr: String(state.current.hour % 10) }, // 分钟和秒同理 ]; units.forEach(unit { if (unit.prev ! unit.curr) { triggerFlip(document.querySelector(unit.selector), unit.prev, unit.curr); } }); }这里我特意计算了previous与current的diff而不是无脑刷新所有位因为一个数字位如果没变化根本不需要跑动画。日期对象提供的getHours()、getMinutes()、getSeconds()返回本地时间这对翻页时钟来说是正确的不需要手动处理UTC偏移。triggerFlip做的事有三件把旧值写进.upper-flip和.lower-flip把新值写进.top-half和.bottom-half然后给动画层加上对应的class让翻转跑起来。动画层跑完后立即移除class并清空内容避免影响下一次动画。2.3 用Date.now()校准定时器漂移setInterval的漂移问题做时钟类项目时必须认真对待。一个更稳的写法是自校准的setTimeoutfunction tick() { const now new Date(); updateClock(now); // 精确对齐到下一秒开始 const delay 1000 - now.getMilliseconds(); setTimeout(tick, delay); }这样每个下一次回调都尽量落在整秒时刻附近长期运行也不会累积偏移。如果你还要考虑系统休眠导致Date.now()大跳变的情况可以在updateClock里加一个判断如果当前时间与上次commit的时间差超过一定阈值就直接跳到最终状态不再播放逐秒动画。这个阈值我习惯设在3秒以上具体原因后面章节会说。3. 翻页触发与边界问题初始化、进位、跨天与后台重入3.1 初始化首帧不要翻牌第一次打开页面时假设现在是10:37:52如果代码按“从00:00:00开始跑”的逻辑一路触发翻页你会看到页面从零开始哗啦哗啦狂翻——这个体验基本是灾难。初始化时所有数字位应该直接渲染当前时间并且让翻转层保持隐藏不播放任何动画。实现方式有两种。一种是在初始化时给所有数字位写入当前值后显式给.upper-flip、.lower-flip设置opacity: 0或者visibility: hidden让它们直接待命。另一种是给整个时钟容器加一个init标记在首次tick时跳过动画逻辑。我推荐第一种因为后续只要没有动画class这两个层本来就不应该影响视觉。要注意的一个细节是翻转层的文字内容也必须初始化成当前值否则第一次真正翻页时upper-flip和lower-flip里还是空的翻动画的时候会出现“下半张牌空白”的bug。所以初始化时每个数字位的四个层都应该设置相同的当前值。3.2 分钟与小时进位时的连跳动效当秒从59跳到00时分位会翻一次如果正好是23:59:59跳到00:00:00秒位、分位、时位三个位置会在同一帧内同时触发翻页。如果所有位置都在同一个tick里瞬间开始翻转视觉上会显得很机械。真实机械翻牌钟因为物理结构的关系各牌片的启动会有微小的时间差反而形成一种独特的韵律感。我在实测中做了一个处理给不同的数字位加上不同的启动延迟。秒位立即翻分位延迟80ms左右时位延迟120ms左右。这个延迟不改变逻辑只是让视觉上有一个“连锁反应”的效果看起来像齿轮传动非常耐看。延迟不能太大超过200ms就会显得拖沓让人误以为是故障。实现上很简单triggerFlip接受一个delay参数内部用setTimeout或者直接在CSS动画里写animation-delay。需要注意如果用了animation-delay动画未启动前翻转层会以初始状态露在外面所以要先把翻转层的初始状态处理成“旧值贴在牌面上”等延迟结束后才开始转动。3.3 后台节流后的追赶逻辑浏览器对后台标签页的定时器节流有多狠实际测过才知道。有时候切走几分钟再切回来所有定时器回调会在短时间内排队执行如果你在tick里每次都触发翻页动画会看到时钟像发了疯一样快速连翻好几十下CPU风扇直接起飞。我的做法是维护一个lastCommitTime记录最后一次真正执行翻页动画的时间戳。每次tick时如果now - lastCommitTime 3000说明中间断了太久这时候直接把当前时间全量渲染到静态层不做任何翻页动画并把lastCommitTime更新为当前时间。只有时间差小于等于3秒时才走正常的diff翻页逻辑。另外建议用visibilitychange事件做一次主动刷新document.addEventListener(visibilitychange, () { if (document.visibilityState visible) { tick(); } });这样切回标签页时能保证立即对齐时间不用等下一个秒定时器唤醒。再配合上面的时间差判断就不会出现动画堆积。4. 让翻转有质感的动效细节半程隐藏、缓动与光影打磨4.1 为什么旋转超过90度必须处理可见性CSS里的rotateX在0度时牌面正对屏幕90度时牌面与屏幕垂直理论上这个瞬间牌面在屏幕上退化成一条线肉眼几乎看不见。但超过90度以后CSS不会自动帮你隐藏牌面它会像一块双面挂牌一样继续露出背面内容。对翻页时钟来说旧数字上半部分在倒下后如果露出背面观众一眼就能发现“这个翻牌是假的”。解决办法是组合使用两个属性backface-visibility: hidden加上适当的z-index切换。对于上半翻转层它从0度倒数到180度当它转过90度时因为背面不可见屏幕上看不到它的背面视觉上它已经“翻过去消失”。对于下半翻转层它从90度开始往0度转初始时牌面是垂直于屏幕的从理论上看不到内容随着旋转归零正面内容出现。背后的原理就是利用“背面隐藏”把超出90度的部分直接裁掉模拟真实翻牌中牌面倒下后的不可见状态。只靠CSS也行但需要精确控制z-index翻转过程中上半翻转层结束后下半翻转层要盖在它上方而下半翻转层结束后常驻新牌面要露出来。我在项目里用jQuery风格控制class的方式已经算过时了现在更推荐用纯CSS的动画阶段切换比如在上半动画最后一行设置visibility: hidden同时给下半动画加一个延迟启动。这样最不容易打架。4.2 缓动函数组合与动画时序翻页效果的真假70%取决于缓动函数选得对不对。真实机械翻牌中牌面先是静止然后被弹簧推动越转越快最后靠阻尼刹住带着一点点回弹。如果上下两半都用linear翻转过程会像齿轮转动一样僵硬如果都用ease-in-out又少了上下半之间那种节奏差。我最终用的组合是.upper-flip { animation: flipTop 600ms cubic-bezier(0.42, 0, 1, 1) forwards; backface-visibility: hidden; } .lower-flip { animation: flipBottom 600ms cubic-bezier(0, 0, 0.58, 1) forwards; backface-visibility: hidden; }flipTop用接近ease-in的曲线模拟牌面被推倒时先慢后快的趋势flipBottom用接近ease-out的曲线模拟从垂直状态翻平后越来越慢的刹车感。上下两瓣配合起来就是一个完整的“先加速后减速”的翻牌过程。动画时长我踩过坑300ms内翻转显得过于急促像截图跳变1000ms以上又太懒像慢动作回放。600ms左右是视觉上最舒服的区间。下半翻转层应该在上半翻转层旋转约90度时再启动实际表现为animation-delay: 300ms正好是上半动画跑到一半的位置这样两段动作无缝衔接。还要注意动画结束后翻转层不能停留在终点状态。使用animation-fill-mode: forwards可以保持终点但是正因为保持终点你如果下次还想重新触发这段动画必须先移除class强制浏览器重排再加回class。比较省心的做法是在动画的animationend事件里彻底清理翻转层的内容和class。我在代码里会这样写flipElement.addEventListener(animationend, () { flipElement.classList.remove(flipping); flipElement.textContent ; flipElement.style.visibility hidden; }, { once: true });4.3 数字牌的光影、字体与性能细节翻转动作对了之后接下来就是质感问题。纯色加粗数字虽然能用但总觉得像纸片。要给牌面一个厚度感我通常做三层处理上半和下半牌面都用linear-gradient模拟顶光比如从上到下由浅到深或者在数字区域加一层细微的内阴影翻转层在旋转过程中用伪元素叠加一个快速扫过的阴影让翻过去那一刻有一个光感变化整体背景用深色渐变数字牌用浅色接近米白中间用低对比度的边框分隔上半和下半。说到阴影翻页时钟里最容易看出质感的一点是下翻页在翻起时会在牌面底部产生一条细长的投影。因为3D旋转时离光源方向不同阴影角度也会变化静态的box-shadow是做不到的所以我用了动态的伪元素透明度和尺寸变化来模拟效果很接近真实。字体方面一定要选等宽字体。数字时钟最大的视觉问题就是数字宽度不一致导致每翻一次牌面左右抖动一下。用等宽数字字体或者给数字牌设置固定宽度和text-align: center配合tabular-nums特性可以确保个位数字和十位数字宽度完全一致翻页时数字位置纹丝不动。性能上最关键的一条是只对transform和opacity做动画。翻牌动画里的rotateX是合成器直接处理的效率很高但如果你顺手把left、top、width、box-shadow这些属性也放进动画浏览器会在每一帧触发布局和绘制低端手机上一个时钟就能把页面拖到满脸掉帧。另外不要贪多给所有数字位开will-change: transform开四五个就够撑爆小设备的GPU内存了最好的做法是动画结束后把这个属性撤掉。记忆点数字翻页效果要做得像核心就两句话——“状态驱动翻页翻转层随动”以及“90度是视觉的临界点背面要藏好”。这两点通了剩下都是锦上添花。最后再分享一个我在实际项目里的习惯把单个数字位封装成组件用>
返回列表