我第一次在某个仪表盘项目里看到翻页时钟(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 的配合
整个时钟主体我习惯用 Flex 水平排列,分成“时”“分”“秒”三组,每一组内部再用 Flex 排列十位和个位两个数字卡。冒号或秒点用独立的 span 元素实现,因为在翻页动画里,冒号并不需要参与翻转。
code复制<div class="flip-clock">
<div class="flip-time-group">
<div class="flip-card" data-digit-pair="hours-tens">...</div>
<div class="flip-card" data-digit-pair="hours-units">...</div>
</div>
<span class="flip-clock-colon">:</span>
<div class="flip-time-group">
<div class="flip-card" data-digit-pair="minutes-tens">...</div>
<div class="flip-card" data-digit-pair="minutes-units">...</div>
</div>
<span class="flip-clock-colon">:</span>
<div class="flip-time-group">
<div class="flip-card" data-digit-pair="seconds-tens">...</div>
<div class="flip-card" data-digit-pair="seconds-units">...</div>
</div>
</div>
布局用 Flex 是为了让三组时间单位在水平方向上均匀分布,不依赖固定像素,屏幕窄了会自动压缩间距。卡片内部的半页对齐则需要精细一点,下面这段 CSS 是我实际项目里用的基础样式,可以直接作为起点:
css复制.flip-clock {
display: flex;
align-items: center;
gap: 24px;
padding: 40px;
background: linear-gradient(145deg, #1e1e2e, #28283a);
border-radius: 24px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.4);
}
.flip-time-group {
display: flex;
gap: 10px;
}
.flip-clock-colon {
font-size: 48px;
font-weight: 300;
color: rgba(255, 255, 255, 0.8);
animation: blink 1s steps(1) infinite;
}
.flip-card {
position: relative;
width: 80px;
height: 120px;
font-size: 72px;
line-height: 120px;
font-weight: 700;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.flip-card-top,
.flip-card-bottom {
position: absolute;
left: 0;
width: 100%;
height: 50%;
overflow: hidden;
}
.flip-card-top {
top: 0;
border-radius: 12px 12px 0 0;
}
.flip-card-bottom {
bottom: 0;
border-radius: 0 0 12px 12px;
}
.flip-card-face {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
backface-visibility: hidden;
}
.flip-card-top .flip-card-num,
.flip-card-bottom .flip-card-num {
position: absolute;
left: 0;
width: 100%;
text-align: center;
}
.flip-card-top .flip-card-num {
top: 0;
height: 200%;
line-height: 120px;
}
.flip-card-bottom .flip-card-num {
bottom: 0;
height: 200%;
line-height: 120px;
}
这段样式的关键在于“半页容器 + 内部数字双倍高度”。.flip-card-top 的高度只有卡片高度的一半,但内部数字的 height: 200% 撑满了完整卡片高度,再用绝对定位对齐到底部或顶部,这样上下半页各显示数字的对应半边,视觉上才能拼成一个完整数字。字体这里我用了很常见的系统无衬线字体,如果你追求更接近复古翻页钟的质感,可以用“Barlow Condensed”之类的高窄字体,后面主题美化部分我会再展开。
2.3 为什么不用绝对定位把每个卡片摆死
实际操作中有个很容易出现的误区:为了让卡片对齐,干脆给六个数字卡都写上 position: absolute,再按固定坐标摆好。这样在固定屏幕上确实没什么问题,但一旦屏幕宽度变化、字号调整或页面缩放,卡片就会错位。用 Flex 布局让卡片自然参与文档流,后续要做响应式只需要调整 .flip-clock 的 gap 和卡片尺寸,所有内部对齐会跟着一起缩放,维护成本低很多。
3. 3D翻页动画的关键:透视、旋转与正反面切换节奏
3.1 透视与 transform-origin:翻页的“轴”在哪里
CSS 3D 变形里,最容易让人一脸懵的是 perspective 和 transform-origin。翻页时钟里,上半卡是绕着卡片顶部那条边旋转的,下半卡是绕着卡片底部那条边旋转的。如果两个卡片的轴设错了,翻到一半数字就会整体偏移。
对应到 CSS 就是:
css复制.flip-card-top {
transform-origin: center bottom;
perspective: 400px;
}
.flip-card-bottom {
transform-origin: center top;
perspective: 400px;
}
这里的关键是:上半卡的旋转轴在底部,也就是靠近卡片中间的那条边;下半卡的旋转轴在顶部,也是靠近卡片中间的那条边。这样它们都围绕同一条“中缝”转动,翻转过程才是连续的。perspective 设置在动画容器上,能让 3D 变形有近大远小的立体效果,数值越小效果越明显。400px 是一个比较稳妥的中间值,太小会有严重的鱼眼变形,太大会显得动画扁平。
3.2 backface-visibility:正反面切换的基础
backface-visibility: hidden 是翻页时钟能不能成立的核心属性。没有它,当卡片旋转超过 90 度时,浏览器会把背面内容镜像显示出来,动画看起来就像在照镜子。
正确做法是:
- 上半卡正面(front)初始可见,背面(back)初始隐藏。
- 下半卡正面初始可见,背面初始隐藏。
- 动画过程中,卡片旋转超过 90 度时,背面开始可见,正面开始隐藏。
因为正反面都是绝对定位并且在同一个容器内,当父容器旋转到 180 度时,背面内容的朝向刚好转过来,视觉上就完成了新旧内容的交替。
3.3 两段关键帧动画的写法
我选择用 CSS 动画类的方式来触发翻页,而不是在 JS 里逐帧操作 style。这样逻辑清晰,也方便后来调整时长。上半卡和下半卡的动画分别命名:
css复制@keyframes flipTop {
0% {
transform: rotateX(0deg);
}
50% {
transform: rotateX(-90deg);
}
100% {
transform: rotateX(-180deg);
}
}
@keyframes flipBottom {
0% {
transform: rotateX(0deg);
}
50% {
transform: rotateX(-90deg);
}
100% {
transform: rotateX(-180deg);
}
}
.flip-card-top.is-flipping {
animation: flipTop 0.35s ease-in forwards;
transform-origin: center bottom;
}
.flip-card-bottom.is-flipping {
animation: flipBottom 0.35s ease-out forwards;
transform-origin: center top;
animation-delay: 0.35s;
}
注意动画的关键帧方向:我写的是 rotateX(-90deg) 和 rotateX(-180deg),也就是向上翻。如果你希望卡片向下落,就需要用正的角度。具体的正负方向和你设置的 perspective 位置相关,建议在浏览器里直接调角度,看到顺眼的旋转方向再固定下来。我个人习惯上半卡向上翻、下半卡向后翻,这样播放起来更像真实的机械结构。
forwards 关键字表示动画结束后保持最后一帧的状态,否则动画结束后卡片会瞬间跳回初始姿态,造成闪烁。这个细节是翻页时钟看起来“干净”和“劣质”的分水岭。
3.4 动画完成后的状态还原
这里有个很多人第一次写会漏掉的步骤。forwards 虽然能让卡片停在动画结束状态,但如果你在下一轮更新时又给同一个元素添加同样的 is-flipping 类,浏览器不会重新播放动画。正确的做法是:在动画结束后,移除 is-flipping 类,同时把正面和背面的内容重新赋值,让“动画结束后的静态状态”成为新的默认状态。
用 JS 监听动画结束事件时,注意上半卡和下半卡的动画时长不同,需要分别监听,或者在上半卡动画结束后再等待 350ms 处理下半卡。我的做法是给两张卡片都添加 animationend 监听,在各自结束的时候独立处理:
javascript复制cardTop.addEventListener('animationend', () => {
cardTop.classList.remove('is-flipping');
// 把背面的内容提为正面显示
syncFaceContent(cardTop);
});
cardBottom.addEventListener('animationend', () => {
cardBottom.classList.remove('is-flipping');
syncFaceContent(cardBottom);
});
这样每次动画结束后,容器回到默认的静态状态,下一轮更新时再添加动画类,就又能重新播放了。
4. 时间驱动的JavaScript逻辑:刷新、进位与动画触发
4.1 避免 setInterval 累积误差
翻页时钟最基础的功能当然是显示时间,但如果你只在页面加载时取一次时间,然后每个 setInterval 都加 1 秒,页面跑上几个小时就会出现明显的偏差。原因很简单:浏览器的定时器会被任务队列阻塞,实际执行间隔并不严格等于 1000ms。
我推荐的做法是:每次更新时用 new Date() 直接读取系统时间,然后从系统时间中解析出时、分、秒。这样即使某个 tick 被延迟到 1500ms 后执行,下一次更新也会自动校正到真实时间,不会越走越偏。
javascript复制function getTimeParts(date = new Date()) {
return {
hours: date.getHours(),
minutes: date.getMinutes(),
seconds: date.getSeconds()
};
}
4.2 用 tick 循环驱动数字变化
为了让时钟每秒刷新,我仍然会用 setInterval,但它的作用仅仅是“提醒我去读取系统时间”,而不是“帮我把时间加一秒”。
javascript复制const flipCards = {
'hours-tens': document.querySelector('[data-digit-pair="hours-tens"]'),
'hours-units': document.querySelector('[data-digit-pair="hours-units"]'),
'minutes-tens': document.querySelector('[data-digit-pair="minutes-tens"]'),
'minutes-units': document.querySelector('[data-digit-pair="minutes-units"]'),
'seconds-tens': document.querySelector('[data-digit-pair="seconds-tens"]'),
'seconds-units': document.querySelector('[data-digit-pair="seconds-units"]')
};
let prevTime = getTimeParts();
function padZero(num) {
return String(num).padStart(2, '0');
}
function updateClock() {
const now = getTimeParts();
const nowStr = {
hours: padZero(now.hours),
minutes: padZero(now.minutes),
seconds: padZero(now.seconds)
};
// 每一位数字需要拆成十位和个位
const secondsTens = nowStr.seconds[0];
const secondsUnits = nowStr.seconds[1];
const minutesTens = nowStr.minutes[0];
const minutesUnits = nowStr.minutes[1];
const hoursTens = nowStr.hours[0];
const hoursUnits = nowStr.hours[1];
// 对比上一秒,只有发生变化的数字才触发翻页
if (prevTime.seconds !== now.seconds) {
if (prevStr.seconds[0] !== secondsTens) flipSingleCard(flipCards['seconds-tens'], secondsTens);
if (prevStr.seconds[1] !== secondsUnits) flipSingleCard(flipCards['seconds-units'], secondsUnits);
}
if (prevTime.minutes !== now.minutes) {
if (prevStr.minutes[0] !== minutesTens) flipSingleCard(flipCards['minutes-tens'], minutesTens);
if (prevStr.minutes[1] !== minutesUnits) flipSingleCard(flipCards['minutes-units'], minutesUnits);
}
if (prevTime.hours !== now.hours) {
if (prevStr.hours[0] !== hoursTens) flipSingleCard(flipCards['hours-tens'], hoursTens);
if (prevStr.hours[1] !== hoursUnits) flipSingleCard(flipCards['hours-units'], hoursUnits);
}
prevTime = now;
}
setInterval(updateClock, 200);
这里我故意把 setInterval 的间隔设成了 200ms,而不是 1000ms。好处是:即使定时器偶尔被延迟,系统时间一旦到达下一秒,最多 200ms 内就会更新界面,误差肉眼不可见。代价仅仅是每秒钟多执行几次比较运算,性能完全可以忽略。
4.3 flipSingleCard 的核心逻辑
flipSingleCard 需要完成这么几件事:在背面写入新数字、为上下半卡添加动画类、动画结束后清理类并同步正面内容。
前面提到,上半卡的背面显示“下一个数字的上半部分”,下半卡的背面显示“下一个数字的下半部分”。但我们要在页面上呈现的,是一个完整的数字被分成两半。所以更新时,需要把新数字同时写入上半卡背面和下半卡背面,然后让它们各自展示自己区域内的那一半。
javascript复制function flipSingleCard(cardEl, newValue) {
const topFaceFront = cardEl.querySelector('.flip-card-top .flip-card-face-front .flip-card-num');
const topFaceBack = cardEl.querySelector('.flip-card-top .flip-card-face-back .flip-card-num');
const bottomFaceFront = cardEl.querySelector('.flip-card-bottom .flip-card-face-front .flip-card-num');
const bottomFaceBack = cardEl.querySelector('.flip-card-bottom .flip-card-face-back .flip-card-num');
if (topFaceBack.textContent === newValue) return;
topFaceBack.textContent = newValue;
bottomFaceBack.textContent = newValue;
const topCard = cardEl.querySelector('.flip-card-top');
const bottomCard = cardEl.querySelector('.flip-card-bottom');
topCard.classList.add('is-flipping');
bottomCard.classList.add('is-flipping');
}
这段代码有一个需要注意的地方:更新背面内容后,你并不能立刻清理动画类。要等到 CSS 动画结束后,再把背面的内容同步到正面。所以 animationend 事件处理器里,不只是移除类,还要做内容同步:
javascript复制function syncFaceContent(cardPart) {
const backFace = cardPart.querySelector('.flip-card-face-back .flip-card-num');
const frontFace = cardPart.querySelector('.flip-card-face-front .flip-card-num');
frontFace.textContent = backFace.textContent;
}
别忘了在动画还没结束的时候,正面显示的还是旧数字,只有等到翻转超过 90 度后,背面内容才开始展示。这就是为什么旧数字会“翻过去”,新数字会“翻出来”的原因。
4.4 边界情况:秒数进位分钟、分钟进位小时
上面的代码已经覆盖了进位逻辑,因为每次刷新都是读取整段系统时间,秒数从 59 变成 0 时,分钟会同步变成新值。这时候分钟卡片和秒卡片会同时翻转,视觉上非常过瘾。小时从 12 变成 1,或 23 变成 0,也同理。
但有一个体验细节值得注意:如果三组卡片同时翻转,每个卡片都在做 3D 动画,浏览器同一时刻要渲染很多层,低端手机会掉帧。我试过在分钟进位那一秒,同时触发秒个位、秒十位、分个位、分十位四张卡片的动画,确实会有轻微卡顿。解决方案有两种:
- 给不同组的卡片动画增加一点延迟,比如秒卡片先翻,50ms 后分卡片再翻,模拟机械结构顺序动作。
- 减少同屏动画层数,比如只在变化发生的单位上做动画,其他单位直接更新文字。
我最终选择了第一种方案,因为顺序动作更真实,而且 50ms 的延迟肉眼很难察觉,但能明显降低渲染峰值压力。
5. 实际踩坑与体验优化:闪烁、跑秒、性能与主题美化
5.1 三个绕不开的经典 Bug
翻页时钟写起来不难,但调起来容易怀疑人生。我总结了自己遇到过的三个频率最高的 bug,以及排查思路。
| 现象 | 根本原因 | 解决办法 |
|---|---|---|
| 翻页时数字闪烁跳动 | 动画结束后未保留状态,或动画类重复添加 | 使用 animation: ... forwards,并在 animationend 里清除动画类;确保更新内容后再触发动画 |
| 动画翻到一半数字错位 | transform-origin 设置错误,或正反面没有先同步尺寸 |
上半卡 origin 设为 center bottom,下半卡设为 center top;正反面内容高度必须一致 |
| 运行几小时后时间偏慢 | 在 setInterval 里对时间做了“加 1 秒”操作 | 改成每次读取 new Date() 的真实时间,定时器只做触发 |
其中第三个问题最隐蔽。如果你用 setInterval(() => { seconds++ }, 1000) 这种写法,哪怕浏览器标签页在后台被节流,定时器暂停几秒后,回来时你也要等上一轮补齐触发,时间就会落后。改用系统时间后,这个问题从根上消失。
5.2 性能优化:GPU 合成与移动端掉帧
翻页时钟特效的本质是 3D 变换,浏览器在动画期间会频繁创建合成层。移动端性能不足时,容易看到卡片边缘发虚、动画掉帧。我做完第一版后在手机上测试,发现翻页音效和动画不同步,再加了视觉检查后发现下半卡在动画起始时有明显闪一下。
排查过程是这样的:我先用 DevTools 的 Performance 面板录制翻页过程,发现 Scripting 和 Rendering 耗时都很高。原因有两个:
- 每一帧都在读取
offsetTop之类的布局属性,导致强制同步布局。 - 卡片层叠关系和阴影重绘次数过多。
优化手段比较有效的有三个:
- 对要动画的卡片添加
will-change: transform,或直接加transform: translateZ(0),让浏览器把卡片提升到独立的合成层。 - 避免在动画期间修改卡片的字体大小、宽度、阴影这类触发重排/重绘的属性。阴影可以在动画结束后再调整。
- 减少不必要的卡片层级。最初我为了让数字更有质感,在每张卡片上叠了好几层内阴影和渐变,结果移动端一翻页就掉帧。后来把数字只保留一层半透明渐变叠加层,视觉差距不大,性能提升明显。
优化后的关键 CSS 示例:
css复制.flip-card-top,
.flip-card-bottom {
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
5.3 主题自定义:用 CSS 变量做深色模式与字体替换
翻页时钟写好之后,下一步自然是让它融入你的页面风格。我不建议每换一个主题就复制整套 CSS 改一遍,更省事的方式是把颜色、字体、卡片尺寸全部抽成 CSS 变量。
css复制:root {
--flip-bg: #1e1e2e;
--flip-card-color: #2e2e42;
--flip-num-color: #f0f0f5;
--flip-shadow: rgba(0, 0, 0, 0.4);
--flip-font: "Barlow Condensed", "Roboto Condensed", sans-serif;
--flip-card-width: 80px;
--flip-card-height: 120px;
--flip-radius: 12px;
}
.flip-clock {
background: var(--flip-bg);
}
.flip-card {
width: var(--flip-card-width);
height: var(--flip-card-height);
border-radius: var(--flip-radius);
font-family: var(--flip-font);
}
想要切换浅色模式,只需要在 body 上挂一个 class,然后覆盖变量值:
css复制.theme-light {
--flip-bg: #eef1f6;
--flip-card-color: #ffffff;
--flip-num-color: #222;
--flip-shadow: rgba(0, 0, 0, 0.1);
}
这样从深色切到浅色,所有卡片颜色、阴影、文字颜色会一起变化,完全不用碰业务逻辑。我把这个技巧也用在博客的首页时钟组件里,后来想增加一个“午夜蓝”主题时,只加了一套变量就完事了。
5.4 关于字体和数字细节
翻页时钟的字体选择很影响整体质感。系统默认的无衬线字体像“Arial”“Microsoft YaHei”用来显示数字,笔画宽度和间距并不均匀,翻起来会显得有点“散”。我用过几款开源的窄体字体,效果比较好的是 Barlow Condensed、Oswald 和 Archivo Narrow。它们共同特点是数字宽度更紧凑,接近真实翻页钟卡片的字体比例。
字重方面,我建议用 600 到 700 之间的粗体,太细的数字在卡片上弱化得很明显。字号可以做到卡片宽度的 0.9 倍左右,比如说卡片宽 80px,字号可以在 64px 到 72px 之间。这里没有绝对标准,你可以快速调两下字号,看数字在上下半页中线位置是否能自然衔接。
另外一个小技巧:数字在卡片中的位置应该垂直居中,但卡片的上下半页通过绝对定位各显示一半。很多人会把下半卡的数字往上偏移一点,这样数字的下半部分和中缝对齐。其实不需要写魔法数字,只需要保证上下半卡内部数字的 line-height 都等于完整卡高,同时分别对齐到顶部和底部,就能做到严格一半的切割效果。
5.5 扩展思路:从时钟到通用翻页组件
写完翻页时钟后,这个动画模型其实可以泛化出很多玩法。比如把卡片内容从数字换成日期,做成“翻页日历”;或者把当前时间和倒计时结合起来,做一个休息提醒计时器;甚至可以把卡片内容换成图片或单词,变成一个闪卡学习组件。
我最早做这个组件只是为了个人博客好看。后来越写越发现,HTML CSS JS 这种“原生三剑客”组合虽然看起来基础,但只要你理解了 3D 变换的旋转轴、正反面隐藏和动画事件这三板斧,就能用最简单的 API 实现很精致的交互效果。翻页时钟是这个原理的最佳入门项目,适合作为你前端动画路上的第一个“有点难度但不太难”的作品。
至于代码怎么组织,我的建议是不要一开始就上模块化。先用一个 HTML 文件把所有 CSS 和 JS 都写进去,把视觉和逻辑都跑通,再根据你自己的项目结构去拆分。拆的时候,只要保证 DOM 结构、CSS 动画类、JS 更新逻辑这三部分边界清晰,后续维护就很简单。
