我第一次在某个仪表盘项目里看到翻页时钟(Flip Clock)的 demo 时,心里只有一个念头:这就是那种“看起来很难,其实拆开就一通百通”的典型前端组件。用原生 HTML、CSS、JS 实现翻页时钟,本质上并不是在做“时钟”,而是在做一场 3D 视觉演出——利用 CSS 的变形与动画节点,配合 JS 的时间驱动,把一个数字“翻转”成另一个数字。整个过程不依赖任何框架或函数库,只要把 DOM 结构、动画时序和状态更新这三件事理顺,就能写出一份稳定、流畅、还能随意换皮肤的翻页时钟。
这篇教程不会只丢一段成品代码让你自己悟。我会把翻页时钟的视觉模型、HTML 结构设计、CSS 3D 动画的实现思路、JavaScript 的时间刷新逻辑全部拆开讲,还会把我实际开发中踩过的几个坑(比如动画闪烁、计时漂移、移动端卡顿)一并分享出来。无论你是刚学完前端基础语法,还是已经在写页面、想给自己的作品加一个亮点,这篇内容应该都能直接上手照着做。
1. 从机械翻页钟到前端动画:视觉原理与结构拆解
想做出一个不像玩具的翻页时钟,第一步不是写代码,而是搞清楚你看到的“翻页”到底是怎么发生的。翻页时钟的原型来自机械翻页钟,它的数字不是 LED 点亮,也不是液晶屏显示,而是由一块块叠在一起的物理卡片构成。每一块卡片分上下两半,中间有一根轴,电机带动卡片从一侧翻到另一侧时,新的数字就像被“翻”出来一样覆盖住旧的数字。
这个物理过程拆到前端里,可以简化成四个关键部位。
1.1 一个数字的“半页”与“正反面”
一个需要变化的数字,从视觉上可以切分为上半页、下半页两个区域。比如数字“6”,它的上半部分是一段弧线,下半部分是另一个弧线加一个小圈。当数字从“6”翻到“7”时,先是上半页的卡片转动,让“7”的上半部分逐渐显现;接着下半页的卡片转动,让“7”的下半部分逐渐显现。看上去是同一块卡片在翻转,实际上你在每一帧看到的画面,是由上一个数字和下一个数字的不同半页共同拼出来的。
所以在 HTML 结构里,每个数字单元都要准备两组“卡片”:
- 上半卡:正面显示当前数字的上半部分,背面显示下一个数字的上半部分。
- 下半卡:正面显示当前数字的下半部分,背面显示下一个数字的下半部分。
不过还有一个容易被忽略的细节:下半卡翻转时,真正需要出现的“下一个数字下半部分”,应该是被上半卡的背面“挡住”之后才露出来的。也就是说,如果你把结构拆得足够细,每个半页卡片至少要包含一个正面对应区域和一个背面对应区域,两个区域通过 CSS 的 3D 翻转切换显隐。
1.2 一次完整翻页的时序:两段动画接力
物理翻页钟每一次数字切换,都是先翻上半页,再翻下半页,中间会有极短的间隔,不会同时翻。这个时间差非常重要,它营造了“机械机构依次动作”的真实感。
实现时我习惯把单个数字的完整变化过程分成两段动画:
- 上半卡先旋转。旋转角度从 0 度到 90 度时,上半卡逐渐立起来;此时背面开始进入观众视野。当旋转到 90 度时,可以认为上半卡处于“水平”状态,这时观众看到的其实是“下一个数字的上半部分”。
- 上半卡继续从 90 度转到 180 度,相当于翻完并倒下;在这个阶段,下半卡开始接力,从 0 度转到 90 度,让下一个数字的下半部分出现。
- 最后下半卡从 90 度转到 180 度,整个新数字完全落下。
如果给两段动画设定同样的时长,视觉上会显得“太平”,像两块铁片在机械地摆动。我试过把上半卡动画设置为 250ms,下半卡从 250ms 开始再跑 250ms,中间加 20-30ms 的停顿,整组动画时长控制在 520ms 左右,看起来最接近真实翻页钟的阻尼感。
1.3 拆解后的渲染层级
每个半页卡片内部的正反面,本质上就是两个绝对定位的层。为了让背面的内容“从背面翻出来”,必须开启父级的 3D 透视,同时给正反面加上 backface-visibility: hidden。这样浏览器在计算渲染时,才会因为旋转角度导致背面朝外时自动隐藏,实现“翻到一半能看到下一数字”的层次感。
理解了这个模型,后面写 HTML 结构和 CSS 动画时,你就能准确知道每个 class 该放在哪里、动画要挂载在哪个节点上,而不是靠猜。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. HTML模板与整体布局:把“时、分、秒”做成独立组件
2.1 数字单元的 DOM 结构
我会把每个数字做成一个独立的 .flip-card 容器,里面再按“上半部分”和“下半部分”分成两个卡片。以十位秒为例,结构建议这样写:
html复制<div class="flip-card" data-digit-pair="seconds-tens">
<div class="flip-card-top">
<div class="flip-card-face flip-card-face-front">
<span class="flip-card-num">0</span>
</div>
<div class="flip-card-face flip-card-face-back">
<span class="flip-card-num">0</span>
</div>
</div>
<div class="flip-card-bottom">
<div class="flip-card-face flip-card-face-front">
<span class="flip-card-num">0</span>
</div>
<div class="flip-card-face flip-card-face-back">
<span class="flip-card-num">0</span>
</div>
</div>
</div>
注意 .flip-card-top 和 .flip-card-bottom 各包含一个正面层(front)和一个背面层(back)。正面层在默认状态下展示当前数字对应的那半部分,背面层在动画过程中展示下一个数字对应的那半部分。
上面的代码里,我用了 data-digit-pair 这个属性来标记某一个数字容器的归属,比如 seconds-tens 表示秒的十位,seconds-units 表示秒的个位。这样后面的 JS 可以直接通过数据属性定位目标节点,避免写很长的选择器链。
可能有人会问:上半部分和下半部分的内容是不是完全一样的?不是的。对于上半卡,正面和背面分别显示“当前数字的上半部分”和“下一个数字的上半部分”;对于下半卡,正面和背面分别显示“当前数字的下半部分”和“下一个数字的下半部分”。如果你在页面上直接给两个 span 填入完整的数字,视觉效果会混乱,必须通过 CSS 裁剪或遮挡来只显示半页范围。
我采用的方式是:让每个半页容器固定高度为整个卡片高度的一半,并设置 overflow: hidden;然后让内部数字 span 的高度等于整卡高度,再通过绝对定位把数字的上半部分对齐到上半页可视区域,或把数字的下半部分对齐到下半页可视区域。具体如何对齐,看下面的 CSS 布局。
2.2 整体布局:Flex 与 Grid 的配合
整个时钟主体我习惯用
