421 行 CSS 做出完整卡牌交互:纯前端动效的克制做法
先看最硬核的一手:翻牌只用一条 transition
塔罗交互的视觉核心是翻牌。这个项目用经典 CSS 3D 双面翻转实现:
/* style.css */
.tarot-card {
width: 130px; height: 200px;
position: relative;
transform-style: preserve-3d;
transition: transform .7s cubic-bezier(.4,.2,.2,1);
}
.tarot-card.is-flipped { transform: rotateY(180deg); }
/* 两面共用 backface-visibility: hidden; */
/* 正面(牌面)预旋转: transform: rotateY(180deg); */
三个关键点:
transform-style: preserve-3d在卡片上建立 3D 上下文,正反两面才能共用一条旋转轴;backface-visibility: hidden保证只有朝外的一面可见——翻转过程中背面不可见,既正确又省绘制;- 正面预先
rotateY(180deg)是这套手法的精髓:父级旋转 180° 时,正面刚好转回朝前,"先翻过去再显示正面"就变成了"翻完正好是正面",动画语义零跳变。
JS 只负责加类:setTimeout(() => inner.classList.add("is-flipped"), 600 + i * 180)——把动画完全交给 CSS transition,代码里没有任何 requestAnimationFrame、没有 Web Animations、没有逐帧驱动。为什么?因为浏览器合成器对 transform 的 transition 是硬件加速路径,让浏览器管动画比自己一帧帧算又稳又快。
cubic-bezier(.4,.2,.2,1) 是 ease-out 风格的缓动——起跳快、收尾慢,符合"翻牌先有动作感、后稳稳落定"的观感。0.7s 的时长既让翻转可感知,又不拖沓。
洗牌与布局:洗牌是纯逻辑,动画在翻牌前
一个容易误解的点:这个项目没有 CSS 层面的洗牌动画。"洗牌"发生在 JavaScript 里、翻牌之前——TAROT_CARDS.slice() 复制全量牌池,循环 Math.floor(Math.random() * pool.length) 取下标、splice 摘除,即无放回的随机抽样;正逆位用 Math.random() < 0.5 掷硬币。
布局用 flex 一行搞定:
.card-area {
display: flex; flex-wrap: wrap; gap: 18px; justify-content: center;
}
每张牌是一个 .tarot-slot(纵排:牌本体 + 位置徽章),卡尺寸 130×200,移动端 640px 断点降为 108×168——一套 flex + 一个媒体查询覆盖了所有屏幕。
入场动画是一段克制的小编排:
.tarot-slot {
animation: slotIn .5s ease both; /* opacity 0→1 + translateY(18px→0) */
}
JS 按 animationDelay = i * 0.18s 逐张错峰入场,翻牌延迟 600 + i*180 紧随其后——入场先错峰,翻牌再错峰,两段式编排让"抽了一排牌"有了先后层次,而不是同时扑出来。
状态机:语义化类 + 单对象 + 两个 setTimeout
整套交互的状态表达很统一:
- CSS 状态全部用语义化类:
.is-flipped(已翻开)、.is-active(牌阵选中/展示区激活)、.is-reversed(逆位卡样式)、按钮:disabled; - JS 状态收敛在单个对象:
state = { selectedSpread, question, drawnCards },配classList.toggle驱动上面的类。
整个流程是一条线性状态链:
选牌阵 → 提问(可不填)→ 抽牌(未选牌阵时按钮禁用)
→ 错峰入场动画 → 交错翻牌 → 渲染解读结果 + scrollIntoView 平滑滚动 → 重置
时序编排手段只有两个 setTimeout(入场延迟和翻牌延迟),总时长 = 600 + 张数×180 + 700。对一个最多 10 张牌的单页面,这就是够用的状态机——不需要状态管理库,不需要动画库,甚至不需要 Promise 链。
这个"极简状态机"还有个值得一提的细节:逆位不旋转几何,只换类。.is-reversed 改变正面的背景色/边框色/关键词色,而不是把牌转 180°——对用户,逆位语义通过视觉色差传达就够了;对浏览器,零布局成本(改颜色只触发 paint,不触发 layout)。
性能:只动合成器属性,其余不做
回看所有动画属性:翻牌 rotateY、入场 opacity + translateY、hover 抬升 translateY——全部是 transform/opacity,没有任何会触发 layout/paint 的 width/left/top 动画。这是性能决策的第一原则:动画只发生在合成器路径上,翻牌 0.7s 在独立合成层运行,不会引起整页重排。
第二个细节是省略:全项目没有 will-change、没有 translateZ(0) 提示。这在"性能优化玄学"盛行的前端圈里反而是清醒的——will-change 是写给浏览器的"提前分配合成层"提示,它对大规模动画有意义;但对 ≤10 张卡、单页面的场景,加了只会多占内存,不给任何可感知的收益。"够用即止"在这里不是偷懒,是对成本的正确评估。
DEVLOG 里能看到这套 CSS 的增长方式:390 行 → 420 行,只加了 30 行。这说明交互增强主要发生在 JS 和引擎层,样式层一直保持稳定——克制不是一次设计出来的,是每次改动都问"这 30 行真有必要吗"长出来的。
总结
421 行 CSS 做出完整卡牌交互,方法论可以浓缩成四条:
- 动画交给 CSS:transform transition + 加类,不用 JS 逐帧;
- 只动合成器属性:transform/opacity 之外不碰,天然高性能;
- 状态用语义化类:is-flipped/is-active/is-reversed,CSS 和 JS 通过类名对话;
- 规模决定取舍:小场景不加 will-change,两个 setTimeout 就够编排时序。
它证明了:纯前端动效的"高级感"不来自技术栈,来自对组合时机和缓动的控制。翻牌、错峰、抬升这三板斧用对,20 行 CSS 就足以撑起一个让人"哇"的瞬间。
- 点赞
- 收藏
- 关注作者
评论(0)